From 620e83f8a65e5b01c4413ff98a589928f57dc033 Mon Sep 17 00:00:00 2001 From: Matias Quaranta Date: Mon, 28 Oct 2019 09:14:48 -0700 Subject: [PATCH] Migrating to V3 SDK --- build/common.props | 2 +- .../Samples/CosmosDBSamples.cs | 21 +- .../Samples/CosmosDBTriggerSamples.cs | 8 +- .../Bindings/CosmosDBAsyncCollector.cs | 29 +- .../Bindings/CosmosDBClientBuilder.cs | 12 +- .../Bindings/CosmosDBEnumerableBuilder.cs | 43 +-- .../Bindings/CosmosDBItemValueBinder.cs | 62 ++-- .../Config/CosmosDBConnectionString.cs | 40 --- .../Config/CosmosDBExtensionConfigProvider.cs | 45 +-- .../Config/CosmosDBOptions.cs | 61 +--- .../CosmosDBWebJobsBuilderExtensions.cs | 1 + .../DefaultCosmosDBSerializerFactory.cs | 12 + .../Config/DefaultCosmosDBServiceFactory.cs | 6 +- .../Config/ICosmosDBSerializerFactory.cs | 19 ++ .../Config/ICosmosDBServiceFactory.cs | 4 +- .../CosmosDBAttribute.cs | 63 ++-- .../CosmosDBContext.cs | 4 +- .../CosmosDBSqlResolutionPolicy.cs | 12 +- .../CosmosDBUtility.cs | 107 +++---- .../Services/CosmosDBService.cs | 114 ------- .../Services/ICosmosDBService.cs | 71 ----- .../Trigger/CosmosDBTriggerAttribute.cs | 95 ++---- ...CosmosDBTriggerAttributeBindingProvider.cs | 191 +++--------- ...riggerAttributeBindingProviderGenerator.cs | 78 +++++ .../Trigger/CosmosDBTriggerBinding.cs | 103 +++---- .../Trigger/CosmosDBTriggerHealthMonitor.cs | 45 --- .../Trigger/CosmosDBTriggerListener.cs | 221 +++++++------- .../Trigger/CosmosDBTriggerMetrics.cs | 7 +- .../Trigger/CosmosDBTriggerObserver.cs | 46 --- .../CosmosDBTriggerParameterDescriptor.cs | 4 +- .../Trigger/CosmosdbTriggerValueBinder.cs | 62 ++++ .../WebJobs.Extensions.CosmosDB.csproj | 5 +- .../CosmosDBAsyncCollectorTests.cs | 76 +++-- .../CosmosDBConfigurationTests.cs | 9 +- .../CosmosDBConnectionStringTests.cs | 32 -- .../CosmosDBEndToEndTests.cs | 38 ++- .../CosmosDBEnumerableBuilderTests.cs | 183 ++++++++--- .../CosmosDBHostBuilderExtensionsTests.cs | 80 ++++- .../CosmosDBItemValueBinderTests.cs | 141 ++++++--- .../CosmosDBMockEndToEndTests.cs | 275 ++++++++++++----- .../CosmosDBSqlResolutionPolicyTests.cs | 17 +- .../CosmosDBTestUtility.cs | 94 +++--- .../CosmosDBUtilityTests.cs | 69 +++-- .../TestCosmosDBServiceFactory.cs | 8 +- .../Trigger/CosmosDBListenerTests.cs | 161 ++++++---- ...sDBTriggerAttributeBindingProviderTests.cs | 286 ++++++------------ .../CosmosDBTriggerHealthMonitorTests.cs | 152 ---------- 47 files changed, 1476 insertions(+), 1738 deletions(-) delete mode 100644 src/WebJobs.Extensions.CosmosDB/Config/CosmosDBConnectionString.cs create mode 100644 src/WebJobs.Extensions.CosmosDB/Config/DefaultCosmosDBSerializerFactory.cs create mode 100644 src/WebJobs.Extensions.CosmosDB/Config/ICosmosDBSerializerFactory.cs delete mode 100644 src/WebJobs.Extensions.CosmosDB/Services/CosmosDBService.cs delete mode 100644 src/WebJobs.Extensions.CosmosDB/Services/ICosmosDBService.cs create mode 100644 src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttributeBindingProviderGenerator.cs delete mode 100644 src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerHealthMonitor.cs delete mode 100644 src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerObserver.cs create mode 100644 src/WebJobs.Extensions.CosmosDB/Trigger/CosmosdbTriggerValueBinder.cs mode change 100755 => 100644 src/WebJobs.Extensions.CosmosDB/WebJobs.Extensions.CosmosDB.csproj delete mode 100644 test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBConnectionStringTests.cs delete mode 100644 test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBTriggerHealthMonitorTests.cs diff --git a/build/common.props b/build/common.props index 68bafa4e8..5aa5e8649 100644 --- a/build/common.props +++ b/build/common.props @@ -3,7 +3,7 @@ 3.0.0$(VersionSuffix) 4.0.3$(VersionSuffix) - 3.0.10$(VersionSuffix) + 4.0.0$(VersionSuffix) 3.1.0$(VersionSuffix) 3.0.0$(VersionSuffix) 3.0.1$(VersionSuffix) diff --git a/src/ExtensionsSample/Samples/CosmosDBSamples.cs b/src/ExtensionsSample/Samples/CosmosDBSamples.cs index 8ddeb8334..9853cc8e3 100644 --- a/src/ExtensionsSample/Samples/CosmosDBSamples.cs +++ b/src/ExtensionsSample/Samples/CosmosDBSamples.cs @@ -3,9 +3,9 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using ExtensionsSample.Models; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Host; using Newtonsoft.Json.Linq; @@ -73,19 +73,22 @@ public static void QueryDocument( } // DocumentClient input binding - //   The binding supplies a DocumentClient directly. + //   The binding supplies a CosmosClient directly. [Disable] - public static void DocumentClient( + public static async Task CosmosClient( [TimerTrigger("00:01", RunOnStartup = true)] TimerInfo timer, - [CosmosDB] DocumentClient client, + [CosmosDB] CosmosClient client, TraceWriter log) { - var collectionUri = UriFactory.CreateDocumentCollectionUri("ItemDb", "ItemCollection"); - var documents = client.CreateDocumentQuery(collectionUri); + var iterator = client.GetContainer("ItemDb", "ItemCollection").GetItemQueryIterator("SELECT * FROM c"); - foreach (Document d in documents) + while (iterator.HasMoreResults) { - log.Info(d.Id); + var documents = await iterator.ReadNextAsync(); + foreach (dynamic d in documents) + { + log.Info(d.id); + } } } } diff --git a/src/ExtensionsSample/Samples/CosmosDBTriggerSamples.cs b/src/ExtensionsSample/Samples/CosmosDBTriggerSamples.cs index 7a65e6cdf..cb0173268 100644 --- a/src/ExtensionsSample/Samples/CosmosDBTriggerSamples.cs +++ b/src/ExtensionsSample/Samples/CosmosDBTriggerSamples.cs @@ -3,9 +3,9 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.Azure.Documents; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Host; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace ExtensionsSample @@ -55,4 +55,10 @@ public static async Task ListenAndCopy( } } } + + public class Document + { + [JsonProperty("id")] + public string Id { get; set; } + } } \ No newline at end of file diff --git a/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBAsyncCollector.cs b/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBAsyncCollector.cs index 943ccadae..4c8bc18ce 100644 --- a/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBAsyncCollector.cs +++ b/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBAsyncCollector.cs @@ -5,8 +5,7 @@ using System.Net; using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB @@ -22,19 +21,20 @@ public CosmosDBAsyncCollector(CosmosDBContext docDBContext) public async Task AddAsync(T item, CancellationToken cancellationToken = default(CancellationToken)) { - bool create = false; try { await UpsertDocument(_docDBContext, item); } catch (Exception ex) { - if (CosmosDBUtility.TryGetDocumentClientException(ex, out DocumentClientException de) && + if (CosmosDBUtility.TryGetCosmosException(ex, out CosmosException de) && de.StatusCode == HttpStatusCode.NotFound) { if (_docDBContext.ResolvedAttribute.CreateIfNotExists) { - create = true; + await CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(_docDBContext); + + await UpsertDocument(_docDBContext, item); } else { @@ -48,13 +48,6 @@ public CosmosDBAsyncCollector(CosmosDBContext docDBContext) throw; } } - - if (create) - { - await CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(_docDBContext); - - await UpsertDocument(_docDBContext, item); - } } public Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken)) @@ -63,18 +56,16 @@ public CosmosDBAsyncCollector(CosmosDBContext docDBContext) return Task.FromResult(0); } - internal static async Task UpsertDocument(CosmosDBContext context, T item) + internal static Task UpsertDocument(CosmosDBContext context, T item) { - Uri collectionUri = UriFactory.CreateDocumentCollectionUri(context.ResolvedAttribute.DatabaseName, context.ResolvedAttribute.CollectionName); - - // DocumentClient does not accept strings directly. - object convertedItem = item; + // Support user sending a string if (item is string) { - convertedItem = JObject.Parse(item.ToString()); + JObject asJObject = JObject.Parse(item.ToString()); + return context.Service.GetContainer(context.ResolvedAttribute.DatabaseName, context.ResolvedAttribute.CollectionName).UpsertItemAsync(asJObject); } - await context.Service.UpsertDocumentAsync(collectionUri, convertedItem); + return context.Service.GetContainer(context.ResolvedAttribute.DatabaseName, context.ResolvedAttribute.CollectionName).UpsertItemAsync(item); } } } diff --git a/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBClientBuilder.cs b/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBClientBuilder.cs index 99cc241ee..8d794f2c9 100644 --- a/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBClientBuilder.cs +++ b/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBClientBuilder.cs @@ -2,11 +2,11 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB.Bindings { - internal class CosmosDBClientBuilder : IConverter + internal class CosmosDBClientBuilder : IConverter { private readonly CosmosDBExtensionConfigProvider _configProvider; @@ -15,7 +15,7 @@ public CosmosDBClientBuilder(CosmosDBExtensionConfigProvider configProvider) _configProvider = configProvider; } - public DocumentClient Convert(CosmosDBAttribute attribute) + public CosmosClient Convert(CosmosDBAttribute attribute) { if (attribute == null) { @@ -23,9 +23,9 @@ public DocumentClient Convert(CosmosDBAttribute attribute) } string resolvedConnectionString = _configProvider.ResolveConnectionString(attribute.ConnectionStringSetting); - ICosmosDBService service = _configProvider.GetService(resolvedConnectionString, attribute.PreferredLocations, attribute.UseMultipleWriteLocations, attribute.UseDefaultJsonSerialization); - - return service.GetClient(); + return _configProvider.GetService( + connectionString: resolvedConnectionString, + preferredLocations: attribute.PreferredLocations); } } } diff --git a/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBEnumerableBuilder.cs b/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBEnumerableBuilder.cs index 6bc072d38..547ac9fcb 100644 --- a/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBEnumerableBuilder.cs +++ b/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBEnumerableBuilder.cs @@ -1,12 +1,10 @@ // 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.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { @@ -24,28 +22,39 @@ public async Task> ConvertAsync(CosmosDBAttribute attribute, Canc { CosmosDBContext context = _configProvider.CreateContext(attribute); - Uri collectionUri = UriFactory.CreateDocumentCollectionUri(context.ResolvedAttribute.DatabaseName, context.ResolvedAttribute.CollectionName); - List finalResults = new List(); - string continuation = null; + Container container = context.Service.GetContainer(context.ResolvedAttribute.DatabaseName, context.ResolvedAttribute.CollectionName); - SqlQuerySpec sqlSpec = new SqlQuerySpec + QueryDefinition queryDefinition = null; + if (!string.IsNullOrEmpty(attribute.SqlQuery)) { - QueryText = context.ResolvedAttribute.SqlQuery, - Parameters = context.ResolvedAttribute.SqlQueryParameters ?? new SqlParameterCollection() - }; + queryDefinition = new QueryDefinition(attribute.SqlQuery); + if (attribute.SqlQueryParameters != null) + { + foreach (var parameter in attribute.SqlQueryParameters) + { + queryDefinition.WithParameter(parameter.Item1, parameter.Item2); + } + } + } - do + QueryRequestOptions queryRequestOptions = new QueryRequestOptions(); + if (!string.IsNullOrEmpty(attribute.PartitionKey)) { - DocumentQueryResponse response = await context.Service.ExecuteNextAsync(collectionUri, sqlSpec, continuation); - - finalResults.AddRange(response.Results); - continuation = response.ResponseContinuation; + queryRequestOptions.PartitionKey = new PartitionKey(attribute.PartitionKey); } - while (!string.IsNullOrEmpty(continuation)); - return finalResults; + using (FeedIterator iterator = container.GetItemQueryIterator(queryDefinition: queryDefinition, requestOptions: queryRequestOptions)) + { + while (iterator.HasMoreResults) + { + FeedResponse response = await iterator.ReadNextAsync(cancellationToken); + finalResults.AddRange(response.Resource); + } + + return finalResults; + } } } } diff --git a/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBItemValueBinder.cs b/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBItemValueBinder.cs index cf7e45e4b..32ab9631f 100644 --- a/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBItemValueBinder.cs +++ b/src/WebJobs.Extensions.CosmosDB/Bindings/CosmosDBItemValueBinder.cs @@ -5,8 +5,7 @@ using System.Net; using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Host.Bindings; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -41,48 +40,35 @@ public async Task SetValueAsync(object value, CancellationToken cancellationToke public async Task GetValueAsync() { - Uri documentUri = UriFactory.CreateDocumentUri(_context.ResolvedAttribute.DatabaseName, _context.ResolvedAttribute.CollectionName, _context.ResolvedAttribute.Id); - RequestOptions options = null; + T document = default(T); - if (!string.IsNullOrEmpty(_context.ResolvedAttribute.PartitionKey)) - { - options = new RequestOptions - { - PartitionKey = new PartitionKey(_context.ResolvedAttribute.PartitionKey) - }; - } - - Document document = null; - - try - { - document = await _context.Service.ReadDocumentAsync(documentUri, options); - } - catch (DocumentClientException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // ignore not found; we'll return null below - } - - if (document == null) - { - return document; - } - - T item = null; + PartitionKey partitionKey = _context.ResolvedAttribute.PartitionKey == null ? PartitionKey.None : new PartitionKey(_context.ResolvedAttribute.PartitionKey); // Strings need to be handled differently. - if (typeof(T) == typeof(string)) + if (typeof(T) != typeof(string)) { - _originalItem = JObject.FromObject(document); - item = _originalItem.ToString(Formatting.None) as T; + try + { + document = await _context.Service.GetContainer(_context.ResolvedAttribute.DatabaseName, _context.ResolvedAttribute.CollectionName) + .ReadItemAsync(_context.ResolvedAttribute.Id, partitionKey); + + _originalItem = JObject.FromObject(document); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // ignore not found; we'll return null below + } } else { - item = (T)(dynamic)document; - _originalItem = JObject.FromObject(item); + JObject jObject = await _context.Service.GetContainer(_context.ResolvedAttribute.DatabaseName, _context.ResolvedAttribute.CollectionName) + .ReadItemAsync(_context.ResolvedAttribute.Id, partitionKey); + _originalItem = jObject; + + document = _originalItem.ToString(Formatting.None) as T; } - return item; + return document; } public string ToInvokeString() @@ -111,7 +97,7 @@ internal static async Task SetValueInternalAsync(JObject originalItem, T newItem // make sure it's not the Id that has changed if (!string.Equals(originalId, currentId, StringComparison.Ordinal)) { - throw new InvalidOperationException("Cannot update the 'Id' property."); + throw new InvalidOperationException("Cannot update the 'id' property."); } } else @@ -121,8 +107,8 @@ internal static async Task SetValueInternalAsync(JObject originalItem, T newItem throw new InvalidOperationException(string.Format("The document must have an 'id' property.")); } - Uri documentUri = UriFactory.CreateDocumentUri(context.ResolvedAttribute.DatabaseName, context.ResolvedAttribute.CollectionName, originalId); - await context.Service.ReplaceDocumentAsync(documentUri, newItem); + Container container = context.Service.GetContainer(context.ResolvedAttribute.DatabaseName, context.ResolvedAttribute.CollectionName); + await container.ReplaceItemAsync(newItem, originalId); } } diff --git a/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBConnectionString.cs b/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBConnectionString.cs deleted file mode 100644 index 82e03c90b..000000000 --- a/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBConnectionString.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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.Data.Common; - -namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB.Config -{ - /// - /// A strongly-typed CosmosDB connection string. DocumentClient does not currently - /// support connection strings so we are using the base DbConnectionStringBuilder to - /// perform the parsing for us. When it is handled by DocumentClient itself, we'll remove - /// this class. - /// - internal class CosmosDBConnectionString - { - public CosmosDBConnectionString(string connectionString) - { - // Use this generic builder to parse the connection string - DbConnectionStringBuilder builder = new DbConnectionStringBuilder - { - ConnectionString = connectionString - }; - - if (builder.TryGetValue("AccountKey", out object key)) - { - AuthKey = key.ToString(); - } - - if (builder.TryGetValue("AccountEndpoint", out object uri)) - { - ServiceEndpoint = new Uri(uri.ToString()); - } - } - - public Uri ServiceEndpoint { get; set; } - - public string AuthKey { get; set; } - } -} diff --git a/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBExtensionConfigProvider.cs b/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBExtensionConfigProvider.cs index 9bdcb7d3e..78d608f81 100644 --- a/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBExtensionConfigProvider.cs +++ b/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBExtensionConfigProvider.cs @@ -5,8 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Description; using Microsoft.Azure.WebJobs.Extensions.CosmosDB.Bindings; using Microsoft.Azure.WebJobs.Host.Bindings; @@ -14,7 +13,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB @@ -27,20 +25,28 @@ internal class CosmosDBExtensionConfigProvider : IExtensionConfigProvider { private readonly IConfiguration _configuration; private readonly ICosmosDBServiceFactory _cosmosDBServiceFactory; + private readonly ICosmosDBSerializerFactory _cosmosSerializerFactory; private readonly INameResolver _nameResolver; private readonly CosmosDBOptions _options; private readonly ILoggerFactory _loggerFactory; - public CosmosDBExtensionConfigProvider(IOptions options, ICosmosDBServiceFactory cosmosDBServiceFactory, IConfiguration configuration, INameResolver nameResolver, ILoggerFactory loggerFactory) + public CosmosDBExtensionConfigProvider( + IOptions options, + ICosmosDBServiceFactory cosmosDBServiceFactory, + ICosmosDBSerializerFactory cosmosSerializerFactory, + IConfiguration configuration, + INameResolver nameResolver, + ILoggerFactory loggerFactory) { _configuration = configuration; _cosmosDBServiceFactory = cosmosDBServiceFactory; + _cosmosSerializerFactory = cosmosSerializerFactory; _nameResolver = nameResolver; _options = options.Value; _loggerFactory = loggerFactory; } - internal ConcurrentDictionary ClientCache { get; } = new ConcurrentDictionary(); + internal ConcurrentDictionary ClientCache { get; } = new ConcurrentDictionary(); /// public void Initialize(ExtensionConfigContext context) @@ -55,7 +61,7 @@ public void Initialize(ExtensionConfigContext context) rule.AddValidator(ValidateConnection); rule.BindToCollector(typeof(CosmosDBCollectorBuilder<>), this); - rule.BindToInput(new CosmosDBClientBuilder(this)); + rule.BindToInput(new CosmosDBClientBuilder(this)); // Enumerable inputs rule.WhenIsNull(nameof(CosmosDBAttribute.Id)) @@ -71,10 +77,7 @@ public void Initialize(ExtensionConfigContext context) // Trigger var rule2 = context.AddBindingRule(); - rule2.BindToTrigger>(new CosmosDBTriggerAttributeBindingProvider(_configuration, _nameResolver, _options, this, _loggerFactory)); - rule2.AddConverter>(str => JsonConvert.DeserializeObject>(str)); - rule2.AddConverter, JArray>(docList => JArray.FromObject(docList)); - rule2.AddConverter, string>(docList => JArray.FromObject(docList).ToString()); + rule2.BindToTrigger(new CosmosDBTriggerAttributeBindingProviderGenerator(_configuration, _nameResolver, _options, this, _loggerFactory)); } internal void ValidateConnection(CosmosDBAttribute attribute, Type paramType) @@ -89,12 +92,12 @@ internal void ValidateConnection(CosmosDBAttribute attribute, Type paramType) } } - internal DocumentClient BindForClient(CosmosDBAttribute attribute) + internal CosmosClient BindForClient(CosmosDBAttribute attribute) { string resolvedConnectionString = ResolveConnectionString(attribute.ConnectionStringSetting); - ICosmosDBService service = GetService(resolvedConnectionString, attribute.PreferredLocations, attribute.UseMultipleWriteLocations, attribute.UseDefaultJsonSerialization); - - return service.GetClient(); + return GetService( + connectionString: resolvedConnectionString, + preferredLocations: attribute.PreferredLocations); } internal Task BindForItemAsync(CosmosDBAttribute attribute, Type type) @@ -124,18 +127,20 @@ internal string ResolveConnectionString(string attributeConnectionString) return _options.ConnectionString; } - internal ICosmosDBService GetService(string connectionString, string preferredLocations = "", bool useMultipleWriteLocations = false, bool useDefaultJsonSerialization = false, string userAgent = "") + internal CosmosClient GetService(string connectionString, string preferredLocations = "", string userAgent = "") { - string cacheKey = BuildCacheKey(connectionString, preferredLocations, useMultipleWriteLocations, useDefaultJsonSerialization); - ConnectionPolicy connectionPolicy = CosmosDBUtility.BuildConnectionPolicy(_options.ConnectionMode, _options.Protocol, preferredLocations, useMultipleWriteLocations, userAgent); - return ClientCache.GetOrAdd(cacheKey, (c) => _cosmosDBServiceFactory.CreateService(connectionString, connectionPolicy, useDefaultJsonSerialization)); + string cacheKey = BuildCacheKey(connectionString, preferredLocations); + CosmosClientOptions cosmosClientOptions = CosmosDBUtility.BuildClientOptions(_options.ConnectionMode, _cosmosSerializerFactory.CreateSerializer(), preferredLocations, userAgent); + return ClientCache.GetOrAdd(cacheKey, (c) => _cosmosDBServiceFactory.CreateService(connectionString, cosmosClientOptions)); } internal CosmosDBContext CreateContext(CosmosDBAttribute attribute) { string resolvedConnectionString = ResolveConnectionString(attribute.ConnectionStringSetting); - ICosmosDBService service = GetService(resolvedConnectionString, attribute.PreferredLocations, attribute.UseMultipleWriteLocations, attribute.UseDefaultJsonSerialization); + CosmosClient service = GetService( + connectionString: resolvedConnectionString, + preferredLocations: attribute.PreferredLocations); return new CosmosDBContext { @@ -155,7 +160,7 @@ internal static bool IsSupportedEnumerable(Type type) return false; } - internal static string BuildCacheKey(string connectionString, string preferredLocations, bool useMultipleWriteLocations, bool useDefaultJsonSerialization) => $"{connectionString}|{preferredLocations}|{useMultipleWriteLocations}|{useDefaultJsonSerialization}"; + internal static string BuildCacheKey(string connectionString, string region) => $"{connectionString}|{region}"; private class DocumentOpenType : OpenType.Poco { diff --git a/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBOptions.cs b/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBOptions.cs index f8d37ee72..fd6d28e40 100644 --- a/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBOptions.cs +++ b/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBOptions.cs @@ -2,8 +2,7 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System.IO; -using Microsoft.Azure.Documents.ChangeFeedProcessor; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Hosting; using Newtonsoft.Json; @@ -17,20 +16,11 @@ public class CosmosDBOptions : IOptionsFormatter public string ConnectionString { get; set; } /// - /// Gets or sets the ConnectionMode used in the DocumentClient instances. + /// Gets or sets the ConnectionMode used in the CosmosClient instances. /// + /// Default is Gateway mode. public ConnectionMode? ConnectionMode { get; set; } - /// - /// Gets or sets the Protocol used in the DocumentClient instances. - /// - public Protocol? Protocol { get; set; } - - /// - /// Gets or sets the lease options for the DocumentDB Trigger. - /// - public ChangeFeedHostOptions LeaseOptions { get; set; } = new ChangeFeedHostOptions(); - public string Format() { StringWriter sw = new StringWriter(); @@ -38,49 +28,8 @@ public string Format() { writer.WriteStartObject(); - writer.WritePropertyName(nameof(ConnectionMode)); - writer.WriteValue(ConnectionMode); - - writer.WritePropertyName(nameof(Protocol)); - writer.WriteValue(Protocol); - - writer.WritePropertyName(nameof(LeaseOptions)); - - writer.WriteStartObject(); - - writer.WritePropertyName(nameof(ChangeFeedHostOptions.CheckpointFrequency)); - writer.WriteStartObject(); - - writer.WritePropertyName(nameof(CheckpointFrequency.ExplicitCheckpoint)); - writer.WriteValue(LeaseOptions.CheckpointFrequency.ExplicitCheckpoint); - - writer.WritePropertyName(nameof(CheckpointFrequency.ProcessedDocumentCount)); - writer.WriteValue(LeaseOptions.CheckpointFrequency.ProcessedDocumentCount); - - writer.WritePropertyName(nameof(CheckpointFrequency.TimeInterval)); - writer.WriteValue(LeaseOptions.CheckpointFrequency.TimeInterval); - - writer.WriteEndObject(); - - writer.WritePropertyName(nameof(ChangeFeedHostOptions.FeedPollDelay)); - writer.WriteValue(LeaseOptions.FeedPollDelay); - - writer.WritePropertyName(nameof(ChangeFeedHostOptions.IsAutoCheckpointEnabled)); - writer.WriteValue(LeaseOptions.IsAutoCheckpointEnabled); - - writer.WritePropertyName(nameof(ChangeFeedHostOptions.LeaseAcquireInterval)); - writer.WriteValue(LeaseOptions.LeaseAcquireInterval); - - writer.WritePropertyName(nameof(ChangeFeedHostOptions.LeaseExpirationInterval)); - writer.WriteValue(LeaseOptions.LeaseExpirationInterval); - - writer.WritePropertyName(nameof(ChangeFeedHostOptions.LeasePrefix)); - writer.WriteValue(LeaseOptions.LeasePrefix); - - writer.WritePropertyName(nameof(ChangeFeedHostOptions.LeaseRenewInterval)); - writer.WriteValue(LeaseOptions.LeaseRenewInterval); - - writer.WriteEndObject(); + writer.WritePropertyName(nameof(this.ConnectionMode)); + writer.WriteValue(this.ConnectionMode); writer.WriteEndObject(); } diff --git a/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBWebJobsBuilderExtensions.cs b/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBWebJobsBuilderExtensions.cs index 89b81ce55..1d4cd580e 100644 --- a/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBWebJobsBuilderExtensions.cs +++ b/src/WebJobs.Extensions.CosmosDB/Config/CosmosDBWebJobsBuilderExtensions.cs @@ -35,6 +35,7 @@ public static IWebJobsBuilder AddCosmosDB(this IWebJobsBuilder builder) }); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); return builder; } diff --git a/src/WebJobs.Extensions.CosmosDB/Config/DefaultCosmosDBSerializerFactory.cs b/src/WebJobs.Extensions.CosmosDB/Config/DefaultCosmosDBSerializerFactory.cs new file mode 100644 index 000000000..b04ce042e --- /dev/null +++ b/src/WebJobs.Extensions.CosmosDB/Config/DefaultCosmosDBSerializerFactory.cs @@ -0,0 +1,12 @@ +// 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.Cosmos; + +namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB +{ + internal class DefaultCosmosDBSerializerFactory : ICosmosDBSerializerFactory + { + public CosmosSerializer CreateSerializer() => null; + } +} diff --git a/src/WebJobs.Extensions.CosmosDB/Config/DefaultCosmosDBServiceFactory.cs b/src/WebJobs.Extensions.CosmosDB/Config/DefaultCosmosDBServiceFactory.cs index 80310fdcb..272403513 100644 --- a/src/WebJobs.Extensions.CosmosDB/Config/DefaultCosmosDBServiceFactory.cs +++ b/src/WebJobs.Extensions.CosmosDB/Config/DefaultCosmosDBServiceFactory.cs @@ -1,15 +1,15 @@ // 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.Documents.Client; +using Microsoft.Azure.Cosmos; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { internal class DefaultCosmosDBServiceFactory : ICosmosDBServiceFactory { - public ICosmosDBService CreateService(string connectionString, ConnectionPolicy connectionPolicy, bool useDefaultJsonSerialization) + public CosmosClient CreateService(string connectionString, CosmosClientOptions cosmosClientOptions) { - return new CosmosDBService(connectionString, connectionPolicy, useDefaultJsonSerialization); + return new CosmosClient(connectionString, cosmosClientOptions); } } } diff --git a/src/WebJobs.Extensions.CosmosDB/Config/ICosmosDBSerializerFactory.cs b/src/WebJobs.Extensions.CosmosDB/Config/ICosmosDBSerializerFactory.cs new file mode 100644 index 000000000..bfb96c84b --- /dev/null +++ b/src/WebJobs.Extensions.CosmosDB/Config/ICosmosDBSerializerFactory.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.Cosmos; + +namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB +{ + /// + /// Factory used to provide a custom to be used with client instances. + /// + public interface ICosmosDBSerializerFactory + { + /// + /// Provides a custom implementation of . + /// + /// + CosmosSerializer CreateSerializer(); + } +} diff --git a/src/WebJobs.Extensions.CosmosDB/Config/ICosmosDBServiceFactory.cs b/src/WebJobs.Extensions.CosmosDB/Config/ICosmosDBServiceFactory.cs index 6e3974287..6636bab1f 100644 --- a/src/WebJobs.Extensions.CosmosDB/Config/ICosmosDBServiceFactory.cs +++ b/src/WebJobs.Extensions.CosmosDB/Config/ICosmosDBServiceFactory.cs @@ -1,12 +1,12 @@ // 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.Documents.Client; +using Microsoft.Azure.Cosmos; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { internal interface ICosmosDBServiceFactory { - ICosmosDBService CreateService(string connectionString, ConnectionPolicy connectionPolicy, bool useDefaultJsonSerialization); + CosmosClient CreateService(string connectionString, CosmosClientOptions cosmosClientOptions); } } diff --git a/src/WebJobs.Extensions.CosmosDB/CosmosDBAttribute.cs b/src/WebJobs.Extensions.CosmosDB/CosmosDBAttribute.cs index a7590d624..ff24342cc 100644 --- a/src/WebJobs.Extensions.CosmosDB/CosmosDBAttribute.cs +++ b/src/WebJobs.Extensions.CosmosDB/CosmosDBAttribute.cs @@ -2,15 +2,15 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; -using Microsoft.Azure.Documents; +using System.Collections.Generic; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Description; using Microsoft.Azure.WebJobs.Extensions.CosmosDB; -using Microsoft.Azure.WebJobs.Hosting; namespace Microsoft.Azure.WebJobs { /// - /// Attribute used to bind to an Azure CosmosDB collection. + /// Attribute used to bind to an Azure Cosmos DB account. /// /// /// The method parameter type can be one of the following: @@ -35,8 +35,8 @@ public CosmosDBAttribute() /// /// Constructs a new instance. /// - /// The CosmosDB database name. - /// The CosmosDB collection name. + /// The Azure Cosmos database name. + /// The Azure Cosmos container name. public CosmosDBAttribute(string databaseName, string collectionName) { DatabaseName = databaseName; @@ -44,44 +44,42 @@ public CosmosDBAttribute(string databaseName, string collectionName) } /// - /// The name of the database to which the parameter applies. + /// Gets the name of the database to which the parameter applies. /// May include binding parameters. /// [AutoResolve] public string DatabaseName { get; private set; } /// - /// The name of the collection to which the parameter applies. + /// Gets the name of the collection to which the parameter applies. /// May include binding parameters. /// [AutoResolve] public string CollectionName { get; private set; } /// - /// Optional. - /// Only applies to output bindings. - /// If true, the database and collection will be automatically created if they do not exist. + /// Gets or sets a value indicating whether the database and collection will be automatically created if they do not exist. /// + /// + /// Only applies to output bindings. + /// public bool CreateIfNotExists { get; set; } /// - /// Optional. A string value indicating the app setting to use as the CosmosDB connection string, if different - /// than the one specified in the . + /// Gets or sets the connection string for the service containing the collection to monitor. /// [ConnectionString] public string ConnectionStringSetting { get; set; } /// - /// Optional. The Id of the document to retrieve from the collection. + /// Gets or sets the Id of the document to retrieve from the collection. /// May include binding parameters. /// [AutoResolve] public string Id { get; set; } /// - /// Optional. - /// When specified on an output binding and is true, defines the partition key - /// path for the created collection. + /// Gets or sets the partition key path to be used if is true on an output binding. /// When specified on an input binding, specifies the partition key value for the lookup. /// May include binding parameters. /// @@ -89,49 +87,28 @@ public CosmosDBAttribute(string databaseName, string collectionName) public string PartitionKey { get; set; } /// - /// Optional. - /// When specified on an output binding and is true, defines the throughput of the created + /// Gets or sets the throughput to be used when creating the collection if is true. /// collection. /// - public int CollectionThroughput { get; set; } + public int? CollectionThroughput { get; set; } /// - /// Optional. - /// When specified on an input binding using an , defines the query to run against the collection. + /// Gets or sets a sql query expression for an input binding to execute on the collection and produce results. /// May include binding parameters. /// [AutoResolve(ResolutionPolicyType = typeof(CosmosDBSqlResolutionPolicy))] public string SqlQuery { get; set; } /// - /// Optional. - /// Enable to use with Multi Master accounts. - /// - public bool UseMultipleWriteLocations { get; set; } - - /// - /// Optional. - /// Enables the use of JsonConvert.DefaultSettings in the monitored Azure Cosmos DB collection. - /// - /// This setting only applies to the monitored collection and the consumer to setup the serialization used in the monitored collection. - /// The JsonConvert.DefaultSettings must be set during the initialization process. - /// This is achieved by deriving a class from and adding a - /// to the assembly that specifies the derived class - /// - /// - public bool UseDefaultJsonSerialization { get; set; } - - /// - /// Optional. - /// Defines preferred locations (regions) for geo-replicated database accounts in the Azure Cosmos DB service. + /// Gets or sets the preferred locations (regions) for geo-replicated database accounts in the Azure Cosmos DB service. /// Values should be comma-separated. /// /// - /// PreferredLocations = "East US,South Central US,North Europe" + /// PreferredLocations = "East US,South Central US,North Europe". /// [AutoResolve] public string PreferredLocations { get; set; } - internal SqlParameterCollection SqlQueryParameters { get; set; } + internal IEnumerable<(string, object)> SqlQueryParameters { get; set; } } } \ No newline at end of file diff --git a/src/WebJobs.Extensions.CosmosDB/CosmosDBContext.cs b/src/WebJobs.Extensions.CosmosDB/CosmosDBContext.cs index 1c04274d9..43ce835e0 100644 --- a/src/WebJobs.Extensions.CosmosDB/CosmosDBContext.cs +++ b/src/WebJobs.Extensions.CosmosDB/CosmosDBContext.cs @@ -1,12 +1,14 @@ // 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.Cosmos; + namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { internal class CosmosDBContext { public CosmosDBAttribute ResolvedAttribute { get; set; } - public ICosmosDBService Service { get; set; } + public CosmosClient Service { get; set; } } } diff --git a/src/WebJobs.Extensions.CosmosDB/CosmosDBSqlResolutionPolicy.cs b/src/WebJobs.Extensions.CosmosDB/CosmosDBSqlResolutionPolicy.cs index c00676356..cd37451aa 100644 --- a/src/WebJobs.Extensions.CosmosDB/CosmosDBSqlResolutionPolicy.cs +++ b/src/WebJobs.Extensions.CosmosDB/CosmosDBSqlResolutionPolicy.cs @@ -6,7 +6,6 @@ using System.Collections.ObjectModel; using System.Linq; using System.Reflection; -using Microsoft.Azure.Documents; using Microsoft.Azure.WebJobs.Host.Bindings; using Microsoft.Azure.WebJobs.Host.Bindings.Path; @@ -32,22 +31,19 @@ public string TemplateBind(PropertyInfo propInfo, Attribute resolvedAttribute, B throw new NotSupportedException($"This policy is only supported for {nameof(CosmosDBAttribute)}."); } - // build a SqlParameterCollection for each parameter - SqlParameterCollection paramCollection = new SqlParameterCollection(); - // also build up a dictionary replacing '{token}' with '@token' IDictionary replacements = new Dictionary(); + List<(string, object)> parameters = new List<(string, object)>(); foreach (var token in bindingTemplate.ParameterNames.Distinct()) { string sqlToken = $"@{token}"; - paramCollection.Add(new SqlParameter(sqlToken, bindingData[token])); + parameters.Add((sqlToken, bindingData[token])); replacements.Add(token, sqlToken); } - docDbAttribute.SqlQueryParameters = paramCollection; + docDbAttribute.SqlQueryParameters = parameters; - string replacement = bindingTemplate.Bind(new ReadOnlyDictionary(replacements)); - return replacement; + return bindingTemplate.Bind(new ReadOnlyDictionary(replacements)); } } } diff --git a/src/WebJobs.Extensions.CosmosDB/CosmosDBUtility.cs b/src/WebJobs.Extensions.CosmosDB/CosmosDBUtility.cs index 6ac7cd653..a4d4afdf1 100644 --- a/src/WebJobs.Extensions.CosmosDB/CosmosDBUtility.cs +++ b/src/WebJobs.Extensions.CosmosDB/CosmosDBUtility.cs @@ -5,19 +5,18 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { internal static class CosmosDBUtility { - internal static bool TryGetDocumentClientException(Exception originalEx, out DocumentClientException documentClientEx) + internal static bool TryGetCosmosException(Exception originalEx, out CosmosException cosmosException) { - documentClientEx = originalEx as DocumentClientException; - - if (documentClientEx != null) + cosmosException = null; + if (originalEx is CosmosException originalCosmosException) { + cosmosException = originalCosmosException; return true; } @@ -27,9 +26,13 @@ internal static bool TryGetDocumentClientException(Exception originalEx, out Doc return false; } - documentClientEx = ae.InnerException as DocumentClientException; + if (ae.InnerException is CosmosException nestedCosmosException) + { + cosmosException = nestedCosmosException; + return true; + } - return documentClientEx != null; + return false; } internal static async Task CreateDatabaseAndCollectionIfNotExistAsync(CosmosDBContext context) @@ -38,83 +41,71 @@ await CreateDatabaseAndCollectionIfNotExistAsync(context.Service, context.Resolv context.ResolvedAttribute.PartitionKey, context.ResolvedAttribute.CollectionThroughput); } - internal static async Task CreateDatabaseAndCollectionIfNotExistAsync(ICosmosDBService service, string databaseName, string collectionName, string partitionKey, int throughput) + internal static async Task CreateDatabaseAndCollectionIfNotExistAsync(CosmosClient service, string databaseName, string containerName, string partitionKey, int? throughput) { - await service.CreateDatabaseIfNotExistsAsync(new Database { Id = databaseName }); + await service.CreateDatabaseIfNotExistsAsync(databaseName); - await CreateDocumentCollectionIfNotExistsAsync(service, databaseName, collectionName, partitionKey, throughput); - } - - internal static IEnumerable ParsePreferredLocations(string preferredRegions) - { - if (string.IsNullOrEmpty(preferredRegions)) + int? desiredThroughput = null; + if (throughput.HasValue && throughput.Value > 0) { - return Enumerable.Empty(); + desiredThroughput = throughput; } - return preferredRegions - .Split(',') - .Select((region) => region.Trim()) - .Where((region) => !string.IsNullOrEmpty(region)); + Database database = service.GetDatabase(databaseName); + + try + { + await database.GetContainer(containerName).ReadContainerAsync(); + } + catch (CosmosException cosmosException) when (cosmosException.StatusCode == System.Net.HttpStatusCode.NotFound) + { + await database.CreateContainerAsync(containerName, partitionKey, desiredThroughput); + } } - internal static ConnectionPolicy BuildConnectionPolicy(ConnectionMode? connectionMode, Protocol? protocol, string preferredLocations, bool useMultipleWriteLocations, string userAgent) + internal static CosmosClientOptions BuildClientOptions(ConnectionMode? connectionMode, CosmosSerializer serializer, string preferredLocations, string userAgent) { - ConnectionPolicy connectionPolicy = new ConnectionPolicy(); + CosmosClientOptions cosmosClientOptions = new CosmosClientOptions(); if (connectionMode.HasValue) { - // Default is Gateway - // Source: https://docs.microsoft.com/dotnet/api/microsoft.azure.documents.client.connectionpolicy.connectionmode - connectionPolicy.ConnectionMode = connectionMode.Value; + cosmosClientOptions.ConnectionMode = connectionMode.Value; } - - if (protocol.HasValue) + else { - connectionPolicy.ConnectionProtocol = protocol.Value; + // Default is Gateway to avoid issues with Functions and consumption plan + cosmosClientOptions.ConnectionMode = ConnectionMode.Gateway; } - if (useMultipleWriteLocations) + if (!string.IsNullOrEmpty(preferredLocations)) { - connectionPolicy.UseMultipleWriteLocations = useMultipleWriteLocations; + cosmosClientOptions.ApplicationPreferredRegions = ParsePreferredLocations(preferredLocations); } - foreach (var location in ParsePreferredLocations(preferredLocations)) + if (!string.IsNullOrEmpty(userAgent)) { - connectionPolicy.PreferredLocations.Add(location); + cosmosClientOptions.ApplicationName = userAgent; } - connectionPolicy.UserAgentSuffix = userAgent; + if (serializer != null) + { + cosmosClientOptions.Serializer = serializer; + } - return connectionPolicy; + return cosmosClientOptions; } - private static async Task CreateDocumentCollectionIfNotExistsAsync(ICosmosDBService service, string databaseName, string collectionName, - string partitionKey, int throughput) + internal static IReadOnlyList ParsePreferredLocations(string preferredRegions) { - Uri databaseUri = UriFactory.CreateDatabaseUri(databaseName); - - DocumentCollection documentCollection = new DocumentCollection - { - Id = collectionName - }; - - if (!string.IsNullOrEmpty(partitionKey)) - { - documentCollection.PartitionKey.Paths.Add(partitionKey); - } - - // If there is any throughput specified, pass it on. DocumentClient will throw with a - // descriptive message if the value does not meet the collection requirements. - RequestOptions collectionOptions = null; - if (throughput != 0) + if (string.IsNullOrEmpty(preferredRegions)) { - collectionOptions = new RequestOptions - { - OfferThroughput = throughput - }; + return Enumerable.Empty().ToList(); } - return await service.CreateDocumentCollectionIfNotExistsAsync(databaseUri, documentCollection, collectionOptions); + return preferredRegions + .Split(',') + .Select((region) => region.Trim()) + .Where((region) => !string.IsNullOrEmpty(region)) + .ToList(); } } } diff --git a/src/WebJobs.Extensions.CosmosDB/Services/CosmosDBService.cs b/src/WebJobs.Extensions.CosmosDB/Services/CosmosDBService.cs deleted file mode 100644 index 9bd1a8dbe..000000000 --- a/src/WebJobs.Extensions.CosmosDB/Services/CosmosDBService.cs +++ /dev/null @@ -1,114 +0,0 @@ -// 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.Documents; -using Microsoft.Azure.Documents.Client; -using Microsoft.Azure.Documents.Linq; -using Microsoft.Azure.WebJobs.Extensions.CosmosDB.Config; -using Newtonsoft.Json; - -namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB -{ - internal sealed class CosmosDBService : ICosmosDBService, IDisposable - { - private bool _isDisposed; - private DocumentClient _client; - - public CosmosDBService(string connectionString, ConnectionPolicy connectionPolicy, bool useDefaultJsonSerialization) - { - CosmosDBConnectionString connection = new CosmosDBConnectionString(connectionString); - if (connectionPolicy == null) - { - connectionPolicy = new ConnectionPolicy(); - } - - if (useDefaultJsonSerialization) - { - if (JsonConvert.DefaultSettings == null) - { - throw new ArgumentNullException("If UseDefaultJsonSerialization is enabled, JsonConvert.DefaultSettings should be configured."); - } - - _client = new DocumentClient(connection.ServiceEndpoint, connection.AuthKey, connectionPolicy, null, JsonConvert.DefaultSettings.Invoke()); - } - else - { - _client = new DocumentClient(connection.ServiceEndpoint, connection.AuthKey, connectionPolicy); - } - } - - public DocumentClient GetClient() - { - return _client; - } - - public async Task CreateDocumentCollectionIfNotExistsAsync(Uri databaseUri, DocumentCollection documentCollection, RequestOptions options) - { - ResourceResponse response = await _client.CreateDocumentCollectionIfNotExistsAsync(databaseUri, documentCollection, options); - return response.Resource; - } - - public async Task CreateDatabaseIfNotExistsAsync(Database database) - { - ResourceResponse response = await _client.CreateDatabaseIfNotExistsAsync(database); - return response.Resource; - } - - public async Task UpsertDocumentAsync(Uri documentCollectionUri, object document) - { - ResourceResponse response = await _client.UpsertDocumentAsync(documentCollectionUri, document); - return response.Resource; - } - - public async Task ReplaceDocumentAsync(Uri documentUri, object document) - { - ResourceResponse response = await _client.ReplaceDocumentAsync(documentUri, document); - return response.Resource; - } - - public async Task ReadDocumentAsync(Uri documentUri, RequestOptions options) - { - ResourceResponse response = await _client.ReadDocumentAsync(documentUri, options); - return response.Resource; - } - - public async Task> ExecuteNextAsync(Uri documentCollectionUri, SqlQuerySpec sqlSpec, string continuation) - { - FeedOptions feedOptions = new FeedOptions { RequestContinuation = continuation, EnableCrossPartitionQuery = true }; - - IDocumentQuery query = null; - if (sqlSpec?.QueryText == null) - { - query = _client.CreateDocumentQuery(documentCollectionUri, feedOptions).AsDocumentQuery(); - } - else - { - query = _client.CreateDocumentQuery(documentCollectionUri, sqlSpec, feedOptions).AsDocumentQuery(); - } - - FeedResponse response = await query.ExecuteNextAsync(); - - return new DocumentQueryResponse - { - Results = response, - ResponseContinuation = response.ResponseContinuation - }; - } - - public void Dispose() - { - if (!_isDisposed) - { - if (_client != null) - { - _client.Dispose(); - _client = null; - } - - _isDisposed = true; - } - } - } -} diff --git a/src/WebJobs.Extensions.CosmosDB/Services/ICosmosDBService.cs b/src/WebJobs.Extensions.CosmosDB/Services/ICosmosDBService.cs deleted file mode 100644 index 62d12aa6d..000000000 --- a/src/WebJobs.Extensions.CosmosDB/Services/ICosmosDBService.cs +++ /dev/null @@ -1,71 +0,0 @@ -// 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.Documents; -using Microsoft.Azure.Documents.Client; - -namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB -{ - /// - /// An abstraction layer for communicating with a CosmosDB account. - /// - internal interface ICosmosDBService - { - /// - /// Creates the specified if it doesn't exists or returns the existing one. - /// - /// The to create. - /// The task object representing the service response for the asynchronous operation. - Task CreateDatabaseIfNotExistsAsync(Database database); - - /// - /// Creates the specified if it doesn't exist or returns the existing one. - /// - /// The self-link of the database to create the collection in. - /// The to create. - /// The for the request. - /// The task object representing the service response for the asynchronous operation. - Task CreateDocumentCollectionIfNotExistsAsync(Uri databaseUri, DocumentCollection documentCollection, RequestOptions options); - - /// - /// Inserts or replaces a document. - /// - /// The self-link of the collection to create the document in. - /// The document object. - /// The task object representing the service response for the asynchronous operation. - Task UpsertDocumentAsync(Uri documentCollectionUri, object document); - - /// - /// Reads a document. - /// - /// The self-link of the document. - /// The for the request. - /// The task object representing the service response for the asynchronous operation. - Task ReadDocumentAsync(Uri documentUri, RequestOptions options); - - /// - /// Replaces a document. - /// - /// The self-link of the collection to create the document in. - /// The to replace. - /// - Task ReplaceDocumentAsync(Uri documentUri, object document); - - /// - /// Queries a collection. - /// - /// The self-link of the collection to query. - /// The SQL expression to query. - /// The continuation token. - /// The response from the call to CosmosDB - Task> ExecuteNextAsync(Uri documentCollectionUri, SqlQuerySpec sqlSpec, string continuation); - - /// - /// Returns the underlying . - /// - /// - DocumentClient GetClient(); - } -} \ No newline at end of file diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttribute.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttribute.cs index c134d69ab..aab088da9 100644 --- a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttribute.cs +++ b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttribute.cs @@ -8,7 +8,7 @@ namespace Microsoft.Azure.WebJobs { /// - /// Defines the [CosmosDBTrigger] attribute + /// Defines the [CosmosDBTrigger] attribute. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1019:DefineAccessorsForAttributeArguments")] [AttributeUsage(AttributeTargets.Parameter)] @@ -16,10 +16,10 @@ namespace Microsoft.Azure.WebJobs public sealed class CosmosDBTriggerAttribute : Attribute { /// - /// Triggers an event when changes occur on a monitored collection + /// Triggers an event when changes occur on a monitored collection. /// - /// Name of the database of the collection to monitor for changes - /// Name of the collection to monitor for changes + /// Name of the database of the collection to monitor for changes. + /// Name of the collection to monitor for changes. public CosmosDBTriggerAttribute(string databaseName, string collectionName) { if (string.IsNullOrWhiteSpace(collectionName)) @@ -39,139 +39,98 @@ public CosmosDBTriggerAttribute(string databaseName, string collectionName) } /// - /// Connection string for the service containing the collection to monitor + /// Gets or sets the connection string for the service containing the collection to monitor. /// - [AppSetting] + [ConnectionString] public string ConnectionStringSetting { get; set; } /// - /// Name of the collection to monitor for changes + /// Gets the name of the collection to monitor for changes. /// public string CollectionName { get; private set; } /// - /// Name of the database containing the collection to monitor for changes + /// Gets the name of the database containing the collection to monitor for changes. /// public string DatabaseName { get; private set; } /// - /// Connection string for the service containing the lease collection + /// Gets or sets the connection string for the service containing the lease collection. /// - [AppSetting] + [ConnectionString] public string LeaseConnectionStringSetting { get; set; } /// - /// Name of the lease collection. Default value is "leases" + /// Gets or sets the name of the lease collection. Default value is "leases". /// public string LeaseCollectionName { get; set; } /// - /// Name of the database containing the lease collection + /// Gets or sets the name of the database containing the lease collection. /// public string LeaseDatabaseName { get; set; } /// - /// Optional. - /// Only applies to lease collection. - /// If true, the database and collection for leases will be automatically created if it does not exist. + /// Gets or sets a value indicating whether the database and collection for leases will be automatically created if it does not exist. /// public bool CreateLeaseCollectionIfNotExists { get; set; } = false; /// - /// Optional. - /// When specified on an output binding and is true, defines the throughput of the created + /// Gets or sets the throughput to be used when creating the collection if is true. /// collection. /// - public int LeasesCollectionThroughput { get; set; } + public int? LeasesCollectionThroughput { get; set; } /// - /// Optional. - /// Defines a prefix to be used within a Leases collection for this Trigger. Useful when sharing the same Lease collection among multiple Triggers + /// Gets or sets a prefix to be used within a Leases collection for this Trigger. Useful when sharing the same Lease collection among multiple Triggers. /// public string LeaseCollectionPrefix { get; set; } /// - /// Optional. - /// Customizes the amount of milliseconds between lease checkpoints. Default is always after a Function call. - /// - public int CheckpointInterval { get; set; } - - /// - /// Optional. - /// Customizes the amount of documents between lease checkpoints. Default is always after a Function call. - /// - public int CheckpointDocumentCount { get; set; } - - /// - /// Optional. - /// Customizes the delay in milliseconds in between polling a partition for new changes on the feed, after all current changes are drained. Default is 5000 (5 seconds). + /// Gets or sets the delay in milliseconds in between polling a partition for new changes on the feed, after all current changes are drained. Default is 5000 (5 seconds). /// public int FeedPollDelay { get; set; } /// - /// Optional. - /// Customizes the renew interval in milliseconds for all leases for partitions currently held by the Trigger. Default is 17000 (17 seconds). + /// Gets or sets the renew interval in milliseconds for all leases for partitions currently held by the Trigger. Default is 17000 (17 seconds). /// public int LeaseRenewInterval { get; set; } /// - /// Optional. - /// Customizes the interval in milliseconds to kick off a task to compute if partitions are distributed evenly among known host instances. Default is 13000 (13 seconds). + /// Gets or sets the interval in milliseconds to kick off a task to compute if partitions are distributed evenly among known host instances. Default is 13000 (13 seconds). /// public int LeaseAcquireInterval { get; set; } - + /// - /// Optional. - /// Customizes the interval in milliseconds for which the lease is taken on a lease representing a partition. If the lease is not renewed within this interval, it will cause it to expire and ownership of the partition will move to another Trigger instance. Default is 60000 (60 seconds). + /// Gets or sets the interval in milliseconds for which the lease is taken on a lease representing a partition. If the lease is not renewed within this interval, it will cause it to expire and ownership of the partition will move to another Trigger instance. Default is 60000 (60 seconds). /// public int LeaseExpirationInterval { get; set; } - + /// - /// Optional. - /// Customizes the maximum amount of items received in an invocation + /// Gets or sets the maximum amount of items received in an invocation. /// public int MaxItemsPerInvocation { get; set; } /// - /// Optional. - /// Gets or sets whether change feed in the Azure Cosmos DB service should start from beginning (true) or from current (false). By default it's start from current (false). + /// Gets or sets a value indicating whether change feed in the Azure Cosmos DB service should start from beginning (true) or from current (false). By default it's start from current (false). /// + /// This is only used to set the initial trigger state. Once the trigger has a lease state, changing this value has no effect. public bool StartFromBeginning { get; set; } = false; /// - /// Optional. /// Gets or sets the a date and time to initialize the change feed read operation from. /// The recommended format is ISO 8601 with the UTC designator. For example: "2021-02-16T14:19:29Z" /// + /// This is only used to set the initial trigger state. Once the trigger has a lease state, changing this value has no effect. public string StartFromTime { get; set; } /// - /// Optional. - /// Defines preferred locations (regions) for geo-replicated database accounts in the Azure Cosmos DB service. + /// Gets or sets the preferred locations (regions) for geo-replicated database accounts in the Azure Cosmos DB service. /// Values should be comma-separated. /// /// /// PreferredLocations = "East US,South Central US,North Europe". /// public string PreferredLocations { get; set; } - - /// - /// Optional. - /// Enable to use with Multi Master accounts. - /// - /// - /// This setting only applies to the Leases collection, as there are no write operations done to the monitored collection. - /// - public bool UseMultipleWriteLocations { get; set; } - - /// - /// Optional. - /// Enables the use of JsonConvert.DefaultSettings in the monitored Azure Cosmos DB collection. - /// - /// This setting only applies to the monitored collection and the consumer to setup the serialization used in the monitored collection. - /// The JsonConvert.DefaultSettings must be set in a class derived from CosmosDBWebJobsStartup. - /// - /// - public bool UseDefaultJsonSerialization { get; set; } } } diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttributeBindingProvider.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttributeBindingProvider.cs index 83ad0c7f4..410d7e2e2 100644 --- a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttributeBindingProvider.cs +++ b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttributeBindingProvider.cs @@ -2,13 +2,9 @@ // 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.Documents; -using Microsoft.Azure.Documents.ChangeFeedProcessor; -using Microsoft.Azure.Documents.Client; -using Microsoft.Azure.WebJobs.Extensions.CosmosDB.Config; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Host; using Microsoft.Azure.WebJobs.Host.Triggers; using Microsoft.Azure.WebJobs.Logging; @@ -17,7 +13,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { - internal class CosmosDBTriggerAttributeBindingProvider : ITriggerBindingProvider + internal class CosmosDBTriggerAttributeBindingProvider { private const string CosmosDBTriggerUserAgentSuffix = "CosmosDBTriggerFunctions"; private const string SharedThroughputRequirementException = "Shared throughput collection should have a partition key"; @@ -53,152 +49,71 @@ public async Task TryCreateAsync(TriggerBindingProviderContext return null; } - ConnectionMode? desiredConnectionMode = _options.ConnectionMode; - Protocol? desiredConnectionProtocol = _options.Protocol; - - DocumentCollectionInfo documentCollectionLocation; - DocumentCollectionInfo leaseCollectionLocation; - ChangeFeedProcessorOptions processorOptions = BuildProcessorOptions(attribute); - - processorOptions.StartFromBeginning = attribute.StartFromBeginning; - - if (attribute.MaxItemsPerInvocation > 0) - { - processorOptions.MaxItemCount = attribute.MaxItemsPerInvocation; - } - - if (!string.IsNullOrEmpty(attribute.StartFromTime)) - { - if (attribute.StartFromBeginning) - { - throw new InvalidOperationException("Only one of StartFromBeginning or StartFromTime can be used"); - } - - if (!DateTime.TryParse(attribute.StartFromTime, out DateTime startFromTime)) - { - throw new InvalidOperationException(@"The specified StartFromTime parameter is not in the correct format. Please use the ISO 8601 format with the UTC designator. For example: '2021-02-16T14:19:29Z'."); - } - - processorOptions.StartTime = startFromTime; - } - else - { - processorOptions.StartFromBeginning = attribute.StartFromBeginning; - } - - ICosmosDBService monitoredCosmosDBService; - ICosmosDBService leaseCosmosDBService; + Container monitoredContainer; + Container leasesContainer; + string monitoredDatabaseName = ResolveAttributeValue(attribute.DatabaseName); + string monitoredCollectionName = ResolveAttributeValue(attribute.CollectionName); + string leasesDatabaseName = ResolveAttributeValue(attribute.LeaseDatabaseName); + string leasesCollectionName = ResolveAttributeValue(attribute.LeaseCollectionName); + string processorName = ResolveAttributeValue(attribute.LeaseCollectionPrefix) ?? string.Empty; try { string triggerConnectionString = ResolveAttributeConnectionString(attribute); - CosmosDBConnectionString triggerConnection = new CosmosDBConnectionString(triggerConnectionString); - if (triggerConnection.ServiceEndpoint == null) + if (string.IsNullOrEmpty(triggerConnectionString)) { throw new InvalidOperationException("The connection string for the monitored collection is in an invalid format, please use AccountEndpoint=XXXXXX;AccountKey=XXXXXX;."); } string leasesConnectionString = ResolveAttributeLeasesConnectionString(attribute); - CosmosDBConnectionString leasesConnection = new CosmosDBConnectionString(leasesConnectionString); - if (leasesConnection.ServiceEndpoint == null) + if (string.IsNullOrEmpty(leasesConnectionString)) { throw new InvalidOperationException("The connection string for the leases collection is in an invalid format, please use AccountEndpoint=XXXXXX;AccountKey=XXXXXX;."); } - documentCollectionLocation = new DocumentCollectionInfo - { - Uri = triggerConnection.ServiceEndpoint, - MasterKey = triggerConnection.AuthKey, - DatabaseName = ResolveAttributeValue(attribute.DatabaseName), - CollectionName = ResolveAttributeValue(attribute.CollectionName) - }; - - documentCollectionLocation.ConnectionPolicy.UserAgentSuffix = CosmosDBTriggerUserAgentSuffix; - - if (desiredConnectionMode.HasValue) - { - documentCollectionLocation.ConnectionPolicy.ConnectionMode = desiredConnectionMode.Value; - } - - if (desiredConnectionProtocol.HasValue) - { - documentCollectionLocation.ConnectionPolicy.ConnectionProtocol = desiredConnectionProtocol.Value; - } - - leaseCollectionLocation = new DocumentCollectionInfo - { - Uri = leasesConnection.ServiceEndpoint, - MasterKey = leasesConnection.AuthKey, - DatabaseName = ResolveAttributeValue(attribute.LeaseDatabaseName), - CollectionName = ResolveAttributeValue(attribute.LeaseCollectionName) - }; - - leaseCollectionLocation.ConnectionPolicy.UserAgentSuffix = CosmosDBTriggerUserAgentSuffix; - - if (desiredConnectionMode.HasValue) - { - leaseCollectionLocation.ConnectionPolicy.ConnectionMode = desiredConnectionMode.Value; - } - - if (desiredConnectionProtocol.HasValue) - { - leaseCollectionLocation.ConnectionPolicy.ConnectionProtocol = desiredConnectionProtocol.Value; - } - - string resolvedPreferredLocations = ResolveAttributeValue(attribute.PreferredLocations); - foreach (var location in CosmosDBUtility.ParsePreferredLocations(resolvedPreferredLocations)) - { - documentCollectionLocation.ConnectionPolicy.PreferredLocations.Add(location); - leaseCollectionLocation.ConnectionPolicy.PreferredLocations.Add(location); - } - - leaseCollectionLocation.ConnectionPolicy.UseMultipleWriteLocations = attribute.UseMultipleWriteLocations; - - if (string.IsNullOrEmpty(documentCollectionLocation.DatabaseName) - || string.IsNullOrEmpty(documentCollectionLocation.CollectionName) - || string.IsNullOrEmpty(leaseCollectionLocation.DatabaseName) - || string.IsNullOrEmpty(leaseCollectionLocation.CollectionName)) + if (string.IsNullOrEmpty(monitoredDatabaseName) + || string.IsNullOrEmpty(monitoredCollectionName) + || string.IsNullOrEmpty(leasesDatabaseName) + || string.IsNullOrEmpty(leasesCollectionName)) { throw new InvalidOperationException("Cannot establish database and collection values. If you are using environment and configuration values, please ensure these are correctly set."); } - if (documentCollectionLocation.Uri.Equals(leaseCollectionLocation.Uri) - && documentCollectionLocation.DatabaseName.Equals(leaseCollectionLocation.DatabaseName) - && documentCollectionLocation.CollectionName.Equals(leaseCollectionLocation.CollectionName)) + if (triggerConnectionString.Equals(leasesConnectionString, StringComparison.InvariantCultureIgnoreCase) + && monitoredDatabaseName.Equals(leasesDatabaseName, StringComparison.InvariantCultureIgnoreCase) + && monitoredCollectionName.Equals(leasesCollectionName, StringComparison.InvariantCultureIgnoreCase)) { throw new InvalidOperationException("The monitored collection cannot be the same as the collection storing the leases."); } - monitoredCosmosDBService = _configProvider.GetService( + CosmosClient monitoredCosmosDBService = _configProvider.GetService( connectionString: triggerConnectionString, - preferredLocations: resolvedPreferredLocations, - useMultipleWriteLocations: false, - useDefaultJsonSerialization: attribute.UseDefaultJsonSerialization, + preferredLocations: attribute.PreferredLocations, userAgent: CosmosDBTriggerUserAgentSuffix); - leaseCosmosDBService = _configProvider.GetService( - connectionString: leasesConnectionString, - preferredLocations: resolvedPreferredLocations, - useMultipleWriteLocations: attribute.UseMultipleWriteLocations, - useDefaultJsonSerialization: false, // Lease collection operations should not be affected by serialization configuration + CosmosClient leaseCosmosDBService = _configProvider.GetService( + connectionString: leasesConnectionString, + preferredLocations: attribute.PreferredLocations, userAgent: CosmosDBTriggerUserAgentSuffix); if (attribute.CreateLeaseCollectionIfNotExists) { - await CreateLeaseCollectionIfNotExistsAsync(leaseCosmosDBService, leaseCollectionLocation.DatabaseName, leaseCollectionLocation.CollectionName, attribute.LeasesCollectionThroughput); + await CreateLeaseCollectionIfNotExistsAsync(leaseCosmosDBService, leasesDatabaseName, leasesCollectionName, attribute.LeasesCollectionThroughput); } + + monitoredContainer = monitoredCosmosDBService.GetContainer(monitoredDatabaseName, monitoredCollectionName); + leasesContainer = leaseCosmosDBService.GetContainer(leasesDatabaseName, leasesCollectionName); } catch (Exception ex) { throw new InvalidOperationException(string.Format("Cannot create Collection Information for {0} in database {1} with lease {2} in database {3} : {4}", attribute.CollectionName, attribute.DatabaseName, attribute.LeaseCollectionName, attribute.LeaseDatabaseName, ex.Message), ex); } - return new CosmosDBTriggerBinding( - parameter, - documentCollectionLocation, - leaseCollectionLocation, - processorOptions, - monitoredCosmosDBService, - leaseCosmosDBService, + return new CosmosDBTriggerBinding( + parameter, + processorName, + monitoredContainer, + leasesContainer, + attribute, _logger); } @@ -217,16 +132,9 @@ internal static TimeSpan ResolveTimeSpanFromMilliseconds(string nameOfProperty, return TimeSpan.FromMilliseconds(attributeValue.Value); } - private static async Task CreateLeaseCollectionIfNotExistsAsync(ICosmosDBService leaseCosmosDBService, string databaseName, string collectionName, int throughput) + private static async Task CreateLeaseCollectionIfNotExistsAsync(CosmosClient cosmosClient, string databaseName, string collectionName, int? throughput) { - try - { - await CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(leaseCosmosDBService, databaseName, collectionName, null, throughput); - } - catch (DocumentClientException ex) when (ex.Message.Contains(SharedThroughputRequirementException)) - { - await CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(leaseCosmosDBService, databaseName, collectionName, LeaseCollectionRequiredPartitionKey, throughput); - } + await CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(cosmosClient, databaseName, collectionName, LeaseCollectionRequiredPartitionKey, throughput); } private string ResolveAttributeConnectionString(CosmosDBTriggerAttribute attribute) @@ -293,36 +201,9 @@ internal string ResolveConnectionString(string unresolvedConnectionString, strin return _options.ConnectionString; } - private ChangeFeedProcessorOptions BuildProcessorOptions(CosmosDBTriggerAttribute attribute) - { - ChangeFeedHostOptions leasesOptions = _options.LeaseOptions; - - ChangeFeedProcessorOptions processorOptions = new ChangeFeedProcessorOptions - { - LeasePrefix = ResolveAttributeValue(attribute.LeaseCollectionPrefix) ?? leasesOptions.LeasePrefix, - FeedPollDelay = ResolveTimeSpanFromMilliseconds(nameof(CosmosDBTriggerAttribute.FeedPollDelay), leasesOptions.FeedPollDelay, attribute.FeedPollDelay), - LeaseAcquireInterval = ResolveTimeSpanFromMilliseconds(nameof(CosmosDBTriggerAttribute.LeaseAcquireInterval), leasesOptions.LeaseAcquireInterval, attribute.LeaseAcquireInterval), - LeaseExpirationInterval = ResolveTimeSpanFromMilliseconds(nameof(CosmosDBTriggerAttribute.LeaseExpirationInterval), leasesOptions.LeaseExpirationInterval, attribute.LeaseExpirationInterval), - LeaseRenewInterval = ResolveTimeSpanFromMilliseconds(nameof(CosmosDBTriggerAttribute.LeaseRenewInterval), leasesOptions.LeaseRenewInterval, attribute.LeaseRenewInterval), - CheckpointFrequency = leasesOptions.CheckpointFrequency ?? new CheckpointFrequency() - }; - - if (attribute.CheckpointInterval > 0) - { - processorOptions.CheckpointFrequency.TimeInterval = TimeSpan.FromMilliseconds(attribute.CheckpointInterval); - } - - if (attribute.CheckpointDocumentCount > 0) - { - processorOptions.CheckpointFrequency.ProcessedDocumentCount = attribute.CheckpointDocumentCount; - } - - return processorOptions; - } - private string ResolveAttributeValue(string attributeValue) { return _nameResolver.ResolveWholeString(attributeValue) ?? attributeValue; } } -} +} \ No newline at end of file diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttributeBindingProviderGenerator.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttributeBindingProviderGenerator.cs new file mode 100644 index 000000000..8dedd5d32 --- /dev/null +++ b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerAttributeBindingProviderGenerator.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 System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Triggers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB +{ + /// + /// Detects the user defined type T on the binding and calls the . + /// + internal class CosmosDBTriggerAttributeBindingProviderGenerator : ITriggerBindingProvider + { + private readonly IConfiguration _configuration; + private readonly INameResolver _nameResolver; + private readonly CosmosDBOptions _options; + private readonly ILoggerFactory _loggerFactory; + private readonly CosmosDBExtensionConfigProvider _configProvider; + + public CosmosDBTriggerAttributeBindingProviderGenerator(IConfiguration configuration, INameResolver nameResolver, CosmosDBOptions options, + CosmosDBExtensionConfigProvider configProvider, ILoggerFactory loggerFactory) + { + _configuration = configuration; + _nameResolver = nameResolver; + _options = options; + _configProvider = configProvider; + _loggerFactory = loggerFactory; + } + + public static Type GetParameterType(ParameterInfo parameter) => parameter.ParameterType.GenericTypeArguments.Length > 0 ? parameter.ParameterType.GenericTypeArguments[0] : parameter.ParameterType; + + public Task TryCreateAsync(TriggerBindingProviderContext context) + { + CosmosDBTriggerAttribute cosmosDBTriggerAttribute = context.Parameter.GetCustomAttribute(inherit: false); + + if (cosmosDBTriggerAttribute == null) + { + return Task.FromResult(null); + } + + Type documentType = CosmosDBTriggerAttributeBindingProviderGenerator.GetParameterType(context.Parameter); + + if (typeof(JArray).IsAssignableFrom(documentType)) + { + documentType = typeof(JObject); // When binding to JArray, use JObject as contract. + } + + // TODO: What's up with string + if (typeof(string).IsAssignableFrom(documentType)) + { + documentType = typeof(JObject); // When binding to JArray, use JObject as contract. + } + + Type baseType = typeof(CosmosDBTriggerAttributeBindingProvider<>); + + Type genericBindingType = baseType.MakeGenericType(documentType); + + Type[] typeArgs = { typeof(IConfiguration), typeof(INameResolver), typeof(CosmosDBOptions), typeof(CosmosDBExtensionConfigProvider), typeof(ILoggerFactory) }; + + ConstructorInfo constructor = genericBindingType.GetConstructor(typeArgs); + + object[] constructorParameterValues = { _configuration, _nameResolver, _options, _configProvider, _loggerFactory }; + + object cosmosDBTriggerAttributeBindingProvider = constructor.Invoke(constructorParameterValues); + + MethodInfo methodInfo = genericBindingType.GetMethod(nameof(CosmosDBTriggerAttributeBindingProvider.TryCreateAsync)); + + object[] methodParameterValues = { context }; + + return (Task)methodInfo.Invoke(cosmosDBTriggerAttributeBindingProvider, methodParameterValues); + } + } +} diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerBinding.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerBinding.cs index cc4575280..9d27a499e 100644 --- a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerBinding.cs +++ b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerBinding.cs @@ -5,66 +5,67 @@ using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.ChangeFeedProcessor; +using Microsoft.Azure.Cosmos; 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.Logging; -using Newtonsoft.Json; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { - internal class CosmosDBTriggerBinding : ITriggerBinding + internal class CosmosDBTriggerBinding : ITriggerBinding { + private static readonly IReadOnlyDictionary _emptyBindingContract = new Dictionary(); private readonly ParameterInfo _parameter; - private readonly DocumentCollectionInfo _documentCollectionLocation; - private readonly DocumentCollectionInfo _leaseCollectionLocation; - private readonly ChangeFeedProcessorOptions _processorOptions; + private readonly string _processorName; private readonly ILogger _logger; - private readonly IReadOnlyDictionary _emptyBindingContract = new Dictionary(); - private readonly IReadOnlyDictionary _emptyBindingData = new Dictionary(); - private readonly ICosmosDBService _monitoredCosmosDBService; - private readonly ICosmosDBService _leasesCosmosDBService; + private readonly Container _monitoredContainer; + private readonly Container _leaseContainer; + private readonly CosmosDBTriggerAttribute _cosmosDBAttribute; - public CosmosDBTriggerBinding(ParameterInfo parameter, - DocumentCollectionInfo documentCollectionLocation, - DocumentCollectionInfo leaseCollectionLocation, - ChangeFeedProcessorOptions processorOptions, - ICosmosDBService monitoredCosmosDBService, - ICosmosDBService leasesCosmosDBService, + public CosmosDBTriggerBinding( + ParameterInfo parameter, + string processorName, + Container monitoredContainer, + Container leaseContainer, + CosmosDBTriggerAttribute cosmosDBAttribute, ILogger logger) { - _documentCollectionLocation = documentCollectionLocation; - _leaseCollectionLocation = leaseCollectionLocation; - _processorOptions = processorOptions; + _monitoredContainer = monitoredContainer; + _leaseContainer = leaseContainer; + _cosmosDBAttribute = cosmosDBAttribute; _parameter = parameter; + _processorName = processorName; _logger = logger; - _monitoredCosmosDBService = monitoredCosmosDBService; - _leasesCosmosDBService = leasesCosmosDBService; } /// /// Gets the type of the value the Trigger receives from the Executor. /// - public Type TriggerValueType => typeof(IReadOnlyList); + public Type TriggerValueType + { + get + { + return typeof(IReadOnlyCollection); + } + } - internal DocumentCollectionInfo DocumentCollectionLocation => _documentCollectionLocation; + internal Container MonitoredContainer => _monitoredContainer; - internal DocumentCollectionInfo LeaseCollectionLocation => _leaseCollectionLocation; + internal Container LeaseContainer => _leaseContainer; - internal ChangeFeedProcessorOptions ChangeFeedProcessorOptions => _processorOptions; + internal string ProcessorName => _processorName; - public IReadOnlyDictionary BindingDataContract - { - get { return _emptyBindingContract; } - } + internal CosmosDBTriggerAttribute CosmosDBAttribute => _cosmosDBAttribute; + + public IReadOnlyDictionary BindingDataContract => CosmosDBTriggerBinding._emptyBindingContract; public Task BindAsync(object value, ValueBindingContext context) { - // ValueProvider is via binding rules. - return Task.FromResult(new TriggerData(null, _emptyBindingData)); + IValueProvider valueBinder = new CosmosDBTriggerValueBinder(_parameter, value); + IReadOnlyDictionary bindingData = GetBindingData(value); + return Task.FromResult(new TriggerData(valueBinder, bindingData)); } [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")] @@ -75,14 +76,13 @@ public Task CreateListenerAsync(ListenerFactoryContext context) throw new ArgumentNullException("context", "Missing listener context"); } - return Task.FromResult(new CosmosDBTriggerListener( + return Task.FromResult(new CosmosDBTriggerListener( context.Executor, context.Descriptor.Id, - this._documentCollectionLocation, - this._leaseCollectionLocation, - this._processorOptions, - this._monitoredCosmosDBService, - this._leasesCosmosDBService, + this._processorName, + this._monitoredContainer, + this._leaseContainer, + this._cosmosDBAttribute, this._logger)); } @@ -96,31 +96,20 @@ public ParameterDescriptor ToParameterDescriptor() { Name = _parameter.Name, Type = CosmosDBTriggerConstants.TriggerName, - CollectionName = this._documentCollectionLocation.CollectionName + CollectionName = this._monitoredContainer.Id }; } - internal static bool TryAndConvertToDocumentList(object value, out IReadOnlyList documents) + private IReadOnlyDictionary GetBindingData(object value) { - documents = null; - - try - { - if (value is IReadOnlyList docs) - { - documents = docs; - } - else if (value is string stringVal) - { - documents = JsonConvert.DeserializeObject>(stringVal); - } - - return documents != null; - } - catch + if (value == null) { - return false; + throw new ArgumentNullException(nameof(value)); } + + Dictionary bindingData = new Dictionary(StringComparer.OrdinalIgnoreCase); + bindingData.Add("CosmosDBTrigger", value); + return bindingData; } } } \ No newline at end of file diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerHealthMonitor.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerHealthMonitor.cs deleted file mode 100644 index 1b74b296b..000000000 --- a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerHealthMonitor.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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.Tasks; -using Microsoft.Azure.Documents.ChangeFeedProcessor.Exceptions; -using Microsoft.Azure.Documents.ChangeFeedProcessor.Monitoring; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB -{ - internal class CosmosDBTriggerHealthMonitor : IHealthMonitor - { - private readonly ILogger _logger; - - public CosmosDBTriggerHealthMonitor(ILogger logger) - { - this._logger = logger; - } - - public Task InspectAsync(HealthMonitoringRecord record) - { - switch (record.Severity) - { - case HealthSeverity.Critical: - this._logger.LogCritical(record.Exception, $"Critical error detected in the operation {record.Operation} for {record.Lease}."); - break; - case HealthSeverity.Error: - if (record.Exception is LeaseLostException) - { - this._logger.LogWarning(record.Exception, $"Lease was lost during operation {record.Operation} for {record.Lease}. This is expected during scaling and briefly during initialization as the leases are rebalanced across instances."); - } - else - { - this._logger.LogError(record.Exception, $"{record.Operation} encountered an error for {record.Lease}."); - } - break; - default: - this._logger.LogTrace($"{record.Operation} on lease {record.Lease}."); - break; - } - - return Task.CompletedTask; - } - } -} diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerListener.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerListener.cs index cc59750f1..eacf60cf0 100644 --- a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerListener.cs +++ b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerListener.cs @@ -8,12 +8,7 @@ using System.Net; using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.ChangeFeedProcessor; -using Microsoft.Azure.Documents.ChangeFeedProcessor.Monitoring; -using Microsoft.Azure.Documents.ChangeFeedProcessor.PartitionManagement; -using Microsoft.Azure.Documents.Client; -using Microsoft.Azure.WebJobs.Extensions.CosmosDB.Trigger; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Host.Executors; using Microsoft.Azure.WebJobs.Host.Listeners; using Microsoft.Azure.WebJobs.Host.Scale; @@ -21,7 +16,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { - internal class CosmosDBTriggerListener : IListener, IScaleMonitor, Microsoft.Azure.Documents.ChangeFeedProcessor.FeedProcessing.IChangeFeedObserverFactory + internal class CosmosDBTriggerListener : IListener, IScaleMonitor { private const int ListenerNotRegistered = 0; private const int ListenerRegistering = 1; @@ -29,20 +24,16 @@ internal class CosmosDBTriggerListener : IListener, IScaleMonitor KnownDocumentClientErrors = new Dictionary() { @@ -59,51 +50,33 @@ internal class CosmosDBTriggerListener : IListener, IScaleMonitor this._scaleMonitorDescriptor; public void Cancel() { this.StopAsync(CancellationToken.None).Wait(); } - public Microsoft.Azure.Documents.ChangeFeedProcessor.FeedProcessing.IChangeFeedObserver CreateObserver() - { - return new CosmosDBTriggerObserver(this._executor); - } - public void Dispose() { //Nothing to dispose @@ -135,10 +108,10 @@ public async Task StartAsync(CancellationToken cancellationToken) this._listenerStatus = ListenerNotRegistered; // Throw a custom error if NotFound. - if (ex is DocumentClientException docEx && docEx.StatusCode == HttpStatusCode.NotFound) + if (ex is CosmosException docEx && docEx.StatusCode == HttpStatusCode.NotFound) { // Throw a custom error so that it's easier to decipher. - string message = $"Either the source collection '{_monitorCollection.CollectionName}' (in database '{_monitorCollection.DatabaseName}') or the lease collection '{_leaseCollection.CollectionName}' (in database '{_leaseCollection.DatabaseName}') does not exist. Both collections must exist before the listener starts. To automatically create the lease collection, set '{nameof(CosmosDBTriggerAttribute.CreateLeaseCollectionIfNotExists)}' to 'true'."; + string message = $"Either the source collection '{this._cosmosDBAttribute.CollectionName}' (in database '{this._cosmosDBAttribute.DatabaseName}') or the lease collection '{this._cosmosDBAttribute.LeaseCollectionName}' (in database '{this._cosmosDBAttribute.LeaseDatabaseName}') does not exist. Both collections must exist before the listener starts. To automatically create the lease collection, set '{nameof(CosmosDBTriggerAttribute.CreateLeaseCollectionIfNotExists)}' to 'true'."; this._host = null; throw new InvalidOperationException(message, ex); } @@ -163,77 +136,107 @@ public async Task StopAsync(CancellationToken cancellationToken) } } - internal virtual async Task StartProcessorAsync() + internal virtual Task StartProcessorAsync() { if (this._host == null) { - this._host = await this._hostBuilder.BuildAsync().ConfigureAwait(false); + this._host = this._hostBuilder.Build(); } - await this._host.StartAsync().ConfigureAwait(false); + return this._host.StartAsync(); } - private void InitializeBuilder() + internal virtual void InitializeBuilder() { if (this._hostBuilder == null) { - this._hostBuilder = new ChangeFeedProcessorBuilder() - .WithHostName(this._hostName) - .WithFeedDocumentClient(this._monitoredCosmosDBService.GetClient()) - .WithLeaseDocumentClient(this._leasesCosmosDBService.GetClient()) - .WithFeedCollection(this._monitorCollection) - .WithLeaseCollection(this._leaseCollection) - .WithProcessorOptions(this._processorOptions) - .WithHealthMonitor(this._healthMonitor) - .WithObserverFactory(this); - } - } + this._hostBuilder = this._monitoredContainer.GetChangeFeedProcessorBuilder(this._processorName, this.ProcessChangesAsync) + .WithInstanceName(this._hostName) + .WithLeaseContainer(this._leaseContainer); - private async Task GetWorkEstimatorAsync() - { - if (_workEstimatorBuilder == null) - { - _workEstimatorBuilder = new ChangeFeedProcessorBuilder() - .WithHostName(this._hostName) - .WithFeedDocumentClient(this._monitoredCosmosDBService.GetClient()) - .WithLeaseDocumentClient(this._leasesCosmosDBService.GetClient()) - .WithFeedCollection(this._monitorCollection) - .WithLeaseCollection(this._leaseCollection) - .WithProcessorOptions(this._processorOptions) - .WithHealthMonitor(this._healthMonitor) - .WithObserverFactory(this); - } + if (this._cosmosDBAttribute.MaxItemsPerInvocation > 0) + { + this._hostBuilder.WithMaxItems(this._cosmosDBAttribute.MaxItemsPerInvocation); + } - if (_workEstimator == null) - { - _workEstimator = await _workEstimatorBuilder.BuildEstimatorAsync(); - } + if (!string.IsNullOrEmpty(this._cosmosDBAttribute.StartFromTime)) + { + if (this._cosmosDBAttribute.StartFromBeginning) + { + throw new InvalidOperationException("Only one of StartFromBeginning or StartFromTime can be used"); + } - return _workEstimator; + if (!DateTime.TryParse(this._cosmosDBAttribute.StartFromTime, out DateTime startFromTime)) + { + throw new InvalidOperationException(@"The specified StartFromTime parameter is not in the correct format. Please use the ISO 8601 format with the UTC designator. For example: '2021-02-16T14:19:29Z'."); + } + + this._hostBuilder.WithStartTime(startFromTime); + } + else + { + if (this._cosmosDBAttribute.StartFromBeginning) + { + this._hostBuilder.WithStartTime(DateTime.MinValue.ToUniversalTime()); + } + } + + if (this._cosmosDBAttribute.FeedPollDelay > 0) + { + this._hostBuilder.WithPollInterval(TimeSpan.FromMilliseconds(this._cosmosDBAttribute.FeedPollDelay)); + } + + TimeSpan? leaseAcquireInterval = null; + if (this._cosmosDBAttribute.LeaseAcquireInterval > 0) + { + leaseAcquireInterval = TimeSpan.FromMilliseconds(this._cosmosDBAttribute.LeaseAcquireInterval); + } + + TimeSpan? leaseExpirationInterval = null; + if (this._cosmosDBAttribute.LeaseExpirationInterval > 0) + { + leaseExpirationInterval = TimeSpan.FromMilliseconds(this._cosmosDBAttribute.LeaseExpirationInterval); + } + + TimeSpan? leaseRenewInterval = null; + if (this._cosmosDBAttribute.LeaseRenewInterval > 0) + { + leaseRenewInterval = TimeSpan.FromMilliseconds(this._cosmosDBAttribute.LeaseRenewInterval); + } + + this._hostBuilder.WithLeaseConfiguration(leaseAcquireInterval, leaseExpirationInterval, leaseRenewInterval); + } } - async Task IScaleMonitor.GetMetricsAsync() + private Task ProcessChangesAsync(IReadOnlyCollection docs, CancellationToken cancellationToken) { - return await GetMetricsAsync(); + return this._executor.TryExecuteAsync(new TriggeredFunctionData() { TriggerValue = docs }, cancellationToken); } public async Task GetMetricsAsync() { int partitionCount = 0; long remainingWork = 0; - IReadOnlyList partitionWorkList = null; try { - IRemainingWorkEstimator workEstimator = await GetWorkEstimatorAsync(); - partitionWorkList = await workEstimator.GetEstimatedRemainingWorkPerPartitionAsync(); + List partitionWorkList = new List(); + ChangeFeedEstimator estimator = this._monitoredContainer.GetChangeFeedEstimator(this._processorName, this._leaseContainer); + using (FeedIterator iterator = estimator.GetCurrentStateIterator()) + { + while (iterator.HasMoreResults) + { + FeedResponse response = await iterator.ReadNextAsync(); + partitionWorkList.AddRange(response); + } + } partitionCount = partitionWorkList.Count; - remainingWork = partitionWorkList.Sum(item => item.RemainingWork); + remainingWork = partitionWorkList.Sum(item => item.EstimatedLag); } - catch (Exception e) when (e is DocumentClientException || e is InvalidOperationException) + catch (Exception e) when (e is CosmosException || e is InvalidOperationException) { - if (!TryHandleDocumentClientException(e)) + if (!TryHandleCosmosException(e)) { _logger.LogWarning("Unable to handle {0}: {1}", e.GetType().ToString(), e.Message); if (e is InvalidOperationException) @@ -275,14 +278,19 @@ public async Task GetMetricsAsync() }; } - ScaleStatus IScaleMonitor.GetScaleStatus(ScaleStatusContext context) + public ScaleStatus GetScaleStatus(ScaleStatusContext context) { return GetScaleStatusCore(context.WorkerCount, context.Metrics?.Cast().ToArray()); } - public ScaleStatus GetScaleStatus(ScaleStatusContext context) + async Task IScaleMonitor.GetMetricsAsync() { - return GetScaleStatusCore(context.WorkerCount, context.Metrics?.ToArray()); + return await GetMetricsAsync(); + } + + public ScaleStatus GetScaleStatus(ScaleStatusContext context) + { + return GetScaleStatusCore(context.WorkerCount, context.Metrics?.Cast().ToArray()); } private ScaleStatus GetScaleStatusCore(int workerCount, CosmosDBTriggerMetrics[] metrics) @@ -308,7 +316,7 @@ private ScaleStatus GetScaleStatusCore(int workerCount, CosmosDBTriggerMetrics[] status.Vote = ScaleVote.ScaleIn; _logger.LogInformation(string.Format($"WorkerCount ({workerCount}) > PartitionCount ({partitionCount}).")); _logger.LogInformation(string.Format($"Number of instances ({workerCount}) is too high relative to number " + - $"of partitions for collection ({this._monitorCollection.CollectionName}, {partitionCount}).")); + $"of partitions for collection ({this._monitoredContainer.Id}, {partitionCount}).")); return status; } @@ -324,7 +332,7 @@ private ScaleStatus GetScaleStatusCore(int workerCount, CosmosDBTriggerMetrics[] { status.Vote = ScaleVote.ScaleOut; _logger.LogInformation(string.Format($"RemainingWork ({latestRemainingWork}) > WorkerCount ({workerCount}) * 1,000.")); - _logger.LogInformation(string.Format($"Remaining work for collection ({this._monitorCollection.CollectionName}, {latestRemainingWork}) " + + _logger.LogInformation(string.Format($"Remaining work for collection ({this._monitoredContainer.Id}, {latestRemainingWork}) " + $"is too high relative to the number of instances ({workerCount}).")); return status; } @@ -333,7 +341,7 @@ private ScaleStatus GetScaleStatusCore(int workerCount, CosmosDBTriggerMetrics[] if (documentsWaiting && partitionCount > 0 && partitionCount > workerCount) { status.Vote = ScaleVote.ScaleOut; - _logger.LogInformation(string.Format($"CosmosDB collection '{this._monitorCollection.CollectionName}' has documents waiting to be processed.")); + _logger.LogInformation(string.Format($"CosmosDB collection '{this._monitoredContainer.Id}' has documents waiting to be processed.")); _logger.LogInformation(string.Format($"There are {workerCount} instances relative to {partitionCount} partitions.")); return status; } @@ -343,7 +351,7 @@ private ScaleStatus GetScaleStatusCore(int workerCount, CosmosDBTriggerMetrics[] if (isIdle) { status.Vote = ScaleVote.ScaleIn; - _logger.LogInformation(string.Format($"'{this._monitorCollection.CollectionName}' is idle.")); + _logger.LogInformation(string.Format($"'{this._monitoredContainer.Id}' is idle.")); return status; } @@ -357,7 +365,7 @@ private ScaleStatus GetScaleStatusCore(int workerCount, CosmosDBTriggerMetrics[] if (remainingWorkIncreasing) { status.Vote = ScaleVote.ScaleOut; - _logger.LogInformation($"Remaining work is increasing for '{this._monitorCollection.CollectionName}'."); + _logger.LogInformation($"Remaining work is increasing for '{this._monitoredContainer.Id}'."); return status; } @@ -369,18 +377,17 @@ private ScaleStatus GetScaleStatusCore(int workerCount, CosmosDBTriggerMetrics[] if (remainingWorkDecreasing) { status.Vote = ScaleVote.ScaleIn; - _logger.LogInformation($"Remaining work is decreasing for '{this._monitorCollection.CollectionName}'."); + _logger.LogInformation($"Remaining work is decreasing for '{this._monitoredContainer.Id}'."); return status; } - _logger.LogInformation($"CosmosDB collection '{this._monitorCollection.CollectionName}' is steady."); + _logger.LogInformation($"CosmosDB collection '{this._monitoredContainer.Id}' is steady."); return status; } - // Since all exceptions in the Document client are thrown as DocumentClientExceptions, we have to parse their error strings because we dont have access to the internal types - // In the form Microsoft.Azure.Documents.DocumentClientException or Microsoft.Azure.Documents.UnauthorizedException - private bool TryHandleDocumentClientException(Exception exception) + // Since all exceptions in the Cosmos client are thrown as CosmosExceptions, we have to parse their error strings because we dont have access to the internal types + private bool TryHandleCosmosException(Exception exception) { string errormsg = null; string exceptionMessage = exception.Message; diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerMetrics.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerMetrics.cs index 71fa4382f..65c3c5d29 100644 --- a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerMetrics.cs +++ b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerMetrics.cs @@ -1,12 +1,9 @@ // 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.WebJobs.Host.Scale; -namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB.Trigger +namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { internal class CosmosDBTriggerMetrics : ScaleMetrics { @@ -14,4 +11,4 @@ internal class CosmosDBTriggerMetrics : ScaleMetrics public long RemainingWork { get; set; } } -} +} \ No newline at end of file diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerObserver.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerObserver.cs deleted file mode 100644 index 6a5de0de7..000000000 --- a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerObserver.cs +++ /dev/null @@ -1,46 +0,0 @@ -// 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.Threading; -using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.ChangeFeedProcessor.FeedProcessing; -using Microsoft.Azure.WebJobs.Host.Executors; - -namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB -{ - internal class CosmosDBTriggerObserver : IChangeFeedObserver - { - private readonly ITriggeredFunctionExecutor executor; - - public CosmosDBTriggerObserver(ITriggeredFunctionExecutor executor) - { - this.executor = executor; - } - - public Task CloseAsync(IChangeFeedObserverContext context, ChangeFeedObserverCloseReason reason) - { - if (context == null) - { - throw new ArgumentNullException("context", "Missing observer context"); - } - return Task.CompletedTask; - } - - public Task OpenAsync(IChangeFeedObserverContext context) - { - if (context == null) - { - throw new ArgumentNullException("context", "Missing observer context"); - } - return Task.CompletedTask; - } - - public Task ProcessChangesAsync(IChangeFeedObserverContext context, IReadOnlyList docs, CancellationToken cancellationToken) - { - return this.executor.TryExecuteAsync(new TriggeredFunctionData() { TriggerValue = docs }, cancellationToken); - } - } -} diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerParameterDescriptor.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerParameterDescriptor.cs index 6ee876f6b..14763c82d 100644 --- a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerParameterDescriptor.cs +++ b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosDBTriggerParameterDescriptor.cs @@ -8,12 +8,12 @@ namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB { /// - /// Trigger parameter descriptor for [CosmosDBTrigger] + /// Trigger parameter descriptor for [CosmosDBTrigger]. /// internal class CosmosDBTriggerParameterDescriptor : TriggerParameterDescriptor { /// - /// Name of the collection being monitored + /// Gets or sets the name of the collection being monitored. /// public string CollectionName { get; set; } diff --git a/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosdbTriggerValueBinder.cs b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosdbTriggerValueBinder.cs new file mode 100644 index 000000000..1b26f53ef --- /dev/null +++ b/src/WebJobs.Extensions.CosmosDB/Trigger/CosmosdbTriggerValueBinder.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.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB +{ + internal class CosmosDBTriggerValueBinder : IValueProvider + { + private readonly object _value; + private readonly ParameterInfo _parameter; + private readonly bool _isJArray; + private readonly bool _isString; + + public CosmosDBTriggerValueBinder( + ParameterInfo parameter, + object value) + { + _value = value; + _parameter = parameter; + Type parameterType = CosmosDBTriggerAttributeBindingProviderGenerator.GetParameterType(parameter); + _isJArray = parameterType.IsAssignableFrom(typeof(JArray)); + _isString = parameterType.IsAssignableFrom(typeof(string)); + } + + public Type Type + { + get + { + if (_isJArray + || _isString) + { + return typeof(IReadOnlyCollection); + } + + return _parameter.ParameterType; + } + } + + public Task GetValueAsync() + { + if (_isString) + { + return Task.FromResult(JArray.FromObject(_value).ToString(Newtonsoft.Json.Formatting.None)); + } + + if (_isJArray) + { + return Task.FromResult(JArray.FromObject(_value)); + } + + return Task.FromResult(_value); + } + + public string ToInvokeString() => string.Empty; + } +} diff --git a/src/WebJobs.Extensions.CosmosDB/WebJobs.Extensions.CosmosDB.csproj b/src/WebJobs.Extensions.CosmosDB/WebJobs.Extensions.CosmosDB.csproj old mode 100755 new mode 100644 index 1c538841f..45b0f4004 --- a/src/WebJobs.Extensions.CosmosDB/WebJobs.Extensions.CosmosDB.csproj +++ b/src/WebJobs.Extensions.CosmosDB/WebJobs.Extensions.CosmosDB.csproj @@ -19,12 +19,11 @@ - - + - + \ No newline at end of file diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBAsyncCollectorTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBAsyncCollectorTests.cs index 26c327126..e6decbe68 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBAsyncCollectorTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBAsyncCollectorTests.cs @@ -3,8 +3,9 @@ using System; using System.Net; +using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Extensions.Tests.Extensions.CosmosDB.Models; using Moq; using Xunit; @@ -17,62 +18,82 @@ public class CosmosDBAsyncCollectorTests public async Task AddAsync_CreatesDocument() { // Arrange - var mockDocDBService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); - mockDocDBService - .Setup(m => m.UpsertDocumentAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new Document()); + var mockContainer = new Mock(MockBehavior.Strict); - var context = CosmosDBTestUtility.CreateContext(mockDocDBService.Object); + mockService + .Setup(m => m.GetContainer(It.Is(d => d == CosmosDBTestUtility.DatabaseName), It.Is(c => c == CosmosDBTestUtility.ContainerName))) + .Returns(mockContainer.Object); + + var mockResponse = new Mock>(MockBehavior.Strict); + mockContainer + .Setup(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockResponse.Object); + + var context = CosmosDBTestUtility.CreateContext(mockService.Object); var collector = new CosmosDBAsyncCollector(context); // Act await collector.AddAsync(new Item { Text = "hello!" }); // Assert - mockDocDBService.VerifyAll(); + mockService.VerifyAll(); } [Fact] public async Task AddAsync_ThrowsWithCustomMessage_IfNotFound() { // Arrange - var mockDocDBService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); + + var mockContainer = new Mock(MockBehavior.Strict); + + mockService + .Setup(m => m.GetContainer(It.Is(d => d == CosmosDBTestUtility.DatabaseName), It.Is(c => c == CosmosDBTestUtility.ContainerName))) + .Returns(mockContainer.Object); - mockDocDBService - .Setup(m => m.UpsertDocumentAsync(It.IsAny(), It.IsAny())) + mockContainer + .Setup(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(CosmosDBTestUtility.CreateDocumentClientException(HttpStatusCode.NotFound)); - var context = CosmosDBTestUtility.CreateContext(mockDocDBService.Object, createIfNotExists: false); + var context = CosmosDBTestUtility.CreateContext(mockService.Object, createIfNotExists: false); var collector = new CosmosDBAsyncCollector(context); // Act var ex = await Assert.ThrowsAsync(() => collector.AddAsync(new Item { Text = "hello!" })); // Assert - Assert.Contains(CosmosDBTestUtility.CollectionName, ex.Message); + Assert.Contains(CosmosDBTestUtility.ContainerName, ex.Message); Assert.Contains(CosmosDBTestUtility.DatabaseName, ex.Message); - mockDocDBService.VerifyAll(); + mockService.VerifyAll(); } [Fact] public async Task AddAsync_DoesNotCreate_IfUpsertSucceeds() { // Arrange - var mockDocDBService = new Mock(MockBehavior.Strict); - var context = CosmosDBTestUtility.CreateContext(mockDocDBService.Object); + var mockService = new Mock(MockBehavior.Strict); + var context = CosmosDBTestUtility.CreateContext(mockService.Object); context.ResolvedAttribute.CreateIfNotExists = true; var collector = new CosmosDBAsyncCollector(context); - mockDocDBService - .Setup(m => m.UpsertDocumentAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(new Document())); + var mockContainer = new Mock(MockBehavior.Strict); + + mockService + .Setup(m => m.GetContainer(It.Is(d => d == CosmosDBTestUtility.DatabaseName), It.Is(c => c == CosmosDBTestUtility.ContainerName))) + .Returns(mockContainer.Object); + + var mockResponse = new Mock>(MockBehavior.Strict); + mockContainer + .Setup(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockResponse.Object); //// Act await collector.AddAsync(new Item { Text = "hello!" }); // Assert - mockDocDBService.VerifyAll(); + mockService.VerifyAll(); } [Theory] @@ -81,18 +102,21 @@ public async Task AddAsync_DoesNotCreate_IfUpsertSucceeds() public async Task AddAsync_Creates_IfTrue_AndNotFound(string partitionKeyPath, int collectionThroughput) { // Arrange - var mockService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); CosmosDBContext context = CosmosDBTestUtility.CreateContext(mockService.Object, partitionKeyPath: partitionKeyPath, throughput: collectionThroughput, createIfNotExists: true); var collector = new CosmosDBAsyncCollector(context); + Mock dbMock = CosmosDBTestUtility.SetupDatabaseMock(mockService); + Mock mockContainer = CosmosDBTestUtility.SetupCollectionMock(mockService, dbMock, partitionKeyPath, collectionThroughput); mockService - .SetupSequence(m => m.UpsertDocumentAsync(It.IsAny(), It.IsAny())) + .Setup(m => m.GetContainer(It.Is(d => d == CosmosDBTestUtility.DatabaseName), It.Is(c => c == CosmosDBTestUtility.ContainerName))) + .Returns(mockContainer.Object); + var mockResponse = new Mock>(MockBehavior.Strict); + mockContainer + .SetupSequence(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Throws(CosmosDBTestUtility.CreateDocumentClientException(HttpStatusCode.NotFound)) - .Returns(Task.FromResult(new Document())); - - CosmosDBTestUtility.SetupDatabaseMock(mockService); - CosmosDBTestUtility.SetupCollectionMock(mockService, partitionKeyPath, collectionThroughput); + .ReturnsAsync(mockResponse.Object); // Act await collector.AddAsync(new Item { Text = "hello!" }); @@ -101,7 +125,7 @@ public async Task AddAsync_Creates_IfTrue_AndNotFound(string partitionKeyPath, i mockService.VerifyAll(); // Verify that we upsert again after creation. - mockService.Verify(m => m.UpsertDocumentAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + mockContainer.Verify(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); } } } diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBConfigurationTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBConfigurationTests.cs index 4f02da610..5155d0d3e 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBConfigurationTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBConfigurationTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.Azure.Documents; using Microsoft.Azure.WebJobs.Extensions.Tests.Common; using Microsoft.Azure.WebJobs.Extensions.Tests.Extensions.CosmosDB.Models; using Microsoft.Extensions.Configuration; @@ -24,7 +23,7 @@ public async Task Configuration_Caches_Clients() { // Arrange var options = new CosmosDBOptions { ConnectionString = "AccountEndpoint=https://someuri;AccountKey=c29tZV9rZXk=;" }; - var config = new CosmosDBExtensionConfigProvider(new OptionsWrapper(options), new DefaultCosmosDBServiceFactory(), _emptyConfig, new TestNameResolver(), NullLoggerFactory.Instance); + var config = new CosmosDBExtensionConfigProvider(new OptionsWrapper(options), new DefaultCosmosDBServiceFactory(), new DefaultCosmosDBSerializerFactory(), _emptyConfig, new TestNameResolver(), NullLoggerFactory.Instance); var attribute = new CosmosDBAttribute { Id = "abcdef" }; // Act @@ -74,11 +73,11 @@ public void Resolve_UsesDefault_Last() [Theory] [InlineData(typeof(IEnumerable), true)] - [InlineData(typeof(IEnumerable), true)] + [InlineData(typeof(IEnumerable), true)] [InlineData(typeof(IEnumerable), true)] [InlineData(typeof(JArray), false)] [InlineData(typeof(string), false)] - [InlineData(typeof(List), false)] + [InlineData(typeof(List), false)] public void TryGetEnumerableType(Type type, bool expectedResult) { bool actualResult = CosmosDBExtensionConfigProvider.IsSupportedEnumerable(type); @@ -90,7 +89,7 @@ private CosmosDBExtensionConfigProvider InitializeExtensionConfigProvider(string var options = CosmosDBTestUtility.InitializeOptions(defaultConnStr, optionsConnStr); var factory = new DefaultCosmosDBServiceFactory(); var nameResolver = new TestNameResolver(); - var configProvider = new CosmosDBExtensionConfigProvider(options, factory, _emptyConfig, nameResolver, NullLoggerFactory.Instance); + var configProvider = new CosmosDBExtensionConfigProvider(options, factory, new DefaultCosmosDBSerializerFactory(), _emptyConfig, nameResolver, NullLoggerFactory.Instance); var context = TestHelpers.CreateExtensionConfigContext(nameResolver); diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBConnectionStringTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBConnectionStringTests.cs deleted file mode 100644 index c16191bef..000000000 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBConnectionStringTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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.Extensions.CosmosDB.Config; -using Xunit; - -namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB.Tests -{ - public class CosmosDBConnectionStringTests - { - [Theory] - [InlineData("AccountEndpoint=https://someuri/;AccountKey=some_key;", "https://someuri/", "some_key")] - [InlineData("AccountEndpoint=https://someuri/", "https://someuri/", null)] - [InlineData("AccountKey=some_key", null, "some_key")] - public void Constructor_ParsesCorrectly(string connectionString, string expectedUri, string expectedKey) - { - // Act - var docDBConnStr = new CosmosDBConnectionString(connectionString); - - // Assert - if (expectedUri == null) - { - Assert.Null(docDBConnStr.ServiceEndpoint); - } - else - { - Assert.Equal(expectedUri, docDBConnStr.ServiceEndpoint.ToString()); - } - Assert.Equal(expectedKey, docDBConnStr.AuthKey); - } - } -} diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBEndToEndTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBEndToEndTests.cs index 14e10b700..a3e65852c 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBEndToEndTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBEndToEndTests.cs @@ -6,10 +6,10 @@ using System.Data.Common; using System.Linq; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Extensions.Tests; using Microsoft.Azure.WebJobs.Extensions.Tests.Common; +using Microsoft.Azure.WebJobs.Extensions.Tests.Extensions.CosmosDB.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -44,8 +44,7 @@ public async Task CosmosDBEndToEnd() await host.GetJobHost().CallAsync(nameof(EndToEndTestClass.Outputs), parameter); // Also insert a new Document so we can query on it. - var collectionUri = UriFactory.CreateDocumentCollectionUri(DatabaseName, CollectionName); - var response = await client.UpsertDocumentAsync(collectionUri, new Document()); + var response = await client.GetContainer(DatabaseName, CollectionName).UpsertItemAsync(new Item() { Id = Guid.NewGuid().ToString() }); // Now craft a queue message to send to the Inputs, which will pull these documents. var queueInput = new QueueItem @@ -70,26 +69,23 @@ await TestHelpers.Await(() => .FormattedMessage; JObject loggedOptions = JObject.Parse(optionsMessage.Substring(optionsMessage.IndexOf(Environment.NewLine))); Assert.Null(loggedOptions["ConnectionMode"].Value()); - Assert.False(loggedOptions["LeaseOptions"]["CheckpointFrequency"]["ExplicitCheckpoint"].Value()); - Assert.Equal(TimeSpan.FromSeconds(5).ToString(), loggedOptions["LeaseOptions"]["FeedPollDelay"].Value()); } } - private async Task InitializeDocumentClientAsync(IConfiguration configuration) + private async Task InitializeDocumentClientAsync(IConfiguration configuration) { - var builder = new DbConnectionStringBuilder - { - ConnectionString = configuration.GetConnectionString(Constants.DefaultConnectionStringName) - }; - - var serviceUri = new Uri(builder["AccountEndpoint"].ToString()); - var client = new DocumentClient(serviceUri, builder["AccountKey"].ToString()); + var client = new CosmosClient(configuration.GetConnectionString(Constants.DefaultConnectionStringName)); - var database = new Database() { Id = DatabaseName }; - await client.CreateDatabaseIfNotExistsAsync(database); + Database database = await client.CreateDatabaseIfNotExistsAsync(DatabaseName); - var collection = new DocumentCollection() { Id = CollectionName }; - await client.CreateDocumentCollectionIfNotExistsAsync(UriFactory.CreateDatabaseUri(DatabaseName), collection); + try + { + await database.GetContainer(CollectionName).ReadContainerAsync(); + } + catch (CosmosException cosmosException) when (cosmosException.StatusCode == System.Net.HttpStatusCode.NotFound) + { + await database.CreateContainerAsync(CollectionName, "/id"); + } return client; } @@ -141,7 +137,7 @@ public static async Task Outputs( { for (int i = 0; i < 3; i++) { - await collector.AddAsync(new { input }); + await collector.AddAsync(new { input = input, id = Guid.NewGuid().ToString() }); } } @@ -149,7 +145,7 @@ public static async Task Outputs( public static void Inputs( [QueueTrigger("NotUsed")] QueueItem item, [CosmosDB(DatabaseName, CollectionName, Id = "{DocumentId}")] JObject document, - [CosmosDB(DatabaseName, CollectionName, SqlQuery = "SELECT * FROM c where c.input = {Input}")] IEnumerable documents, + [CosmosDB(DatabaseName, CollectionName, SqlQuery = "SELECT * FROM c where c.input = {Input}")] IEnumerable documents, ILogger log) { Assert.NotNull(document); @@ -157,7 +153,7 @@ public static void Inputs( } public static void Trigger( - [CosmosDBTrigger(DatabaseName, CollectionName, CreateLeaseCollectionIfNotExists = true)]IReadOnlyList documents, + [CosmosDBTrigger(DatabaseName, CollectionName, CreateLeaseCollectionIfNotExists = true)]IReadOnlyList documents, ILogger log) { foreach (var document in documents) diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBEnumerableBuilderTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBEnumerableBuilderTests.cs index 4e361f1b4..7ea6750ac 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBEnumerableBuilderTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBEnumerableBuilderTests.cs @@ -7,8 +7,7 @@ using System.Net; using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Extensions.Tests.Common; using Microsoft.Azure.WebJobs.Extensions.Tests.Extensions.CosmosDB.Models; using Microsoft.Extensions.Configuration; @@ -24,25 +23,83 @@ public class CosmosDBEnumerableBuilderTests private const string DatabaseName = "ItemDb"; private const string CollectionName = "ItemCollection"; private static readonly IConfiguration _emptyConfig = new ConfigurationBuilder().Build(); - private readonly Uri _expectedUri = UriFactory.CreateDocumentCollectionUri(DatabaseName, CollectionName); [Fact] public async Task ConvertAsync_Succeeds_NoContinuation() { - var builder = CreateBuilder(out Mock mockService); + var builder = CreateBuilder(out Mock mockService); + + var mockContainer = new Mock(MockBehavior.Strict); mockService - .Setup(m => m.ExecuteNextAsync(_expectedUri, It.IsAny(), It.IsAny())) - .ReturnsAsync(new DocumentQueryResponse - { - Results = GetDocumentCollection(5), - ResponseContinuation = null - }); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + Mock> mockIterator = new Mock>(); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(mockIterator.Object); + + mockIterator + .SetupSequence(m => m.HasMoreResults) + .Returns(true) + .Returns(false); + + Mock> mockResponse = new Mock>(); + + mockIterator + .Setup(m => m.ReadNextAsync(It.IsAny())) + .ReturnsAsync(mockResponse.Object); + + mockResponse + .Setup(m => m.Resource) + .Returns(GetDocumentCollection(5)); + + CosmosDBAttribute attribute = new CosmosDBAttribute(DatabaseName, CollectionName) + { + SqlQuery = string.Empty + }; + + var results = await builder.ConvertAsync(attribute, CancellationToken.None); + Assert.Equal(5, results.Count()); + } + + [Fact] + public async Task ConvertAsync_Succeeds_NoContinuation_WithPartitionKey() + { + var partitionKey = Guid.NewGuid().ToString(); + var builder = CreateBuilder(out Mock mockService); + + var mockContainer = new Mock(MockBehavior.Strict); + + mockService + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + Mock> mockIterator = new Mock>(); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), It.IsAny(), It.Is(ro => ro.PartitionKey == new PartitionKey(partitionKey)))) + .Returns(mockIterator.Object); + + mockIterator + .SetupSequence(m => m.HasMoreResults) + .Returns(true) + .Returns(false); + + Mock> mockResponse = new Mock>(); + + mockIterator + .Setup(m => m.ReadNextAsync(It.IsAny())) + .ReturnsAsync(mockResponse.Object); + + mockResponse + .Setup(m => m.Resource) + .Returns(GetDocumentCollection(5)); CosmosDBAttribute attribute = new CosmosDBAttribute(DatabaseName, CollectionName) { SqlQuery = string.Empty, - SqlQueryParameters = new SqlParameterCollection() + PartitionKey = partitionKey }; var results = await builder.ConvertAsync(attribute, CancellationToken.None); @@ -52,91 +109,117 @@ public async Task ConvertAsync_Succeeds_NoContinuation() [Fact] public async Task ConvertAsync_Succeeds_WithContinuation() { - var builder = CreateBuilder(out Mock mockService); + var builder = CreateBuilder(out Mock mockService); var docCollection = GetDocumentCollection(17); + var mockContainer = new Mock(MockBehavior.Strict); + mockService - .SetupSequence(m => m.ExecuteNextAsync(_expectedUri, It.IsAny(), It.IsAny())) - .ReturnsAsync(new DocumentQueryResponse - { - Results = docCollection.Take(5), - ResponseContinuation = "1" - }) - .ReturnsAsync(new DocumentQueryResponse - { - Results = docCollection.Skip(5).Take(5), - ResponseContinuation = "2" - }).ReturnsAsync(new DocumentQueryResponse - { - Results = docCollection.Skip(10).Take(5), - ResponseContinuation = "3" - }).ReturnsAsync(new DocumentQueryResponse - { - Results = docCollection.Skip(15).Take(2), - ResponseContinuation = null - }); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + Mock> mockIterator = new Mock>(); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(mockIterator.Object); + + mockIterator + .SetupSequence(m => m.HasMoreResults) + .Returns(true) + .Returns(true) + .Returns(true) + .Returns(true) + .Returns(false); + + Mock> mockResponse = new Mock>(); + + mockIterator + .Setup(m => m.ReadNextAsync(It.IsAny())) + .ReturnsAsync(mockResponse.Object); + + mockResponse + .SetupSequence(m => m.Resource) + .Returns(docCollection.Take(5)) + .Returns(docCollection.Skip(5).Take(5)) + .Returns(docCollection.Skip(10).Take(5)) + .Returns(docCollection.Skip(15).Take(2)); CosmosDBAttribute attribute = new CosmosDBAttribute(DatabaseName, CollectionName) { - SqlQuery = string.Empty, - SqlQueryParameters = new SqlParameterCollection() + SqlQuery = "SELECT * FROM c" }; var results = await builder.ConvertAsync(attribute, CancellationToken.None); Assert.Equal(17, results.Count()); - mockService.Verify(m => m.ExecuteNextAsync(_expectedUri, It.IsAny(), It.IsAny()), Times.Exactly(4)); + mockIterator.Verify(m => m.ReadNextAsync(It.IsAny()), Times.Exactly(4)); } [Fact] public async Task ConvertAsync_RethrowsException_IfNotFound() { - var builder = CreateBuilder(out Mock mockService); + var builder = CreateBuilder(out Mock mockService); + + var mockContainer = new Mock(MockBehavior.Strict); mockService - .Setup(m => m.ExecuteNextAsync(_expectedUri, It.IsAny(), It.IsAny())) - .ThrowsAsync(CosmosDBTestUtility.CreateDocumentClientException((HttpStatusCode)404)); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + Mock> mockIterator = new Mock>(); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(mockIterator.Object); + + mockIterator + .Setup(m => m.HasMoreResults) + .Returns(true); + + Mock> mockResponse = new Mock>(); + + mockIterator + .Setup(m => m.ReadNextAsync(It.IsAny())) + .ThrowsAsync(CosmosDBTestUtility.CreateDocumentClientException(HttpStatusCode.NotFound)); CosmosDBAttribute attribute = new CosmosDBAttribute(DatabaseName, CollectionName) { - SqlQuery = string.Empty, - SqlQueryParameters = new SqlParameterCollection() + SqlQuery = string.Empty }; - var ex = await Assert.ThrowsAsync(() => builder.ConvertAsync(attribute, CancellationToken.None)); - Assert.Equal("NotFound", ex.Error.Code); + var ex = await Assert.ThrowsAsync(() => builder.ConvertAsync(attribute, CancellationToken.None)); + Assert.Equal(HttpStatusCode.NotFound, ex.StatusCode); - mockService.Verify(m => m.ExecuteNextAsync(_expectedUri, It.IsAny(), It.IsAny()), Times.Once()); + mockIterator.Verify(m => m.ReadNextAsync(It.IsAny()), Times.Once()); } - private static IEnumerable GetDocumentCollection(int count) + private static IEnumerable GetDocumentCollection(int count) { - List items = new List(); + List items = new List(); for (int i = 0; i < count; i++) { - var doc = new Document { Id = i.ToString() }; - doc.SetPropertyValue("Text", $"Item {i}"); + var doc = new Item { Id = i.ToString() }; + doc.Text = $"Item {i}"; items.Add(doc); } return items; } - private static CosmosDBEnumerableBuilder CreateBuilder(out Mock mockService) + private static CosmosDBEnumerableBuilder CreateBuilder(out Mock mockService) where T : class { - mockService = new Mock(MockBehavior.Strict); + mockService = new Mock(MockBehavior.Strict); Mock mockServiceFactory = new Mock(MockBehavior.Strict); mockServiceFactory - .Setup(m => m.CreateService(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(m => m.CreateService(It.IsAny(), It.IsAny())) .Returns(mockService.Object); var options = new OptionsWrapper(new CosmosDBOptions { ConnectionString = "AccountEndpoint=https://someuri;AccountKey=c29tZV9rZXk=;" }); - var configProvider = new CosmosDBExtensionConfigProvider(options, mockServiceFactory.Object, _emptyConfig, new TestNameResolver(), NullLoggerFactory.Instance); + var configProvider = new CosmosDBExtensionConfigProvider(options, mockServiceFactory.Object, new DefaultCosmosDBSerializerFactory(), _emptyConfig, new TestNameResolver(), NullLoggerFactory.Instance); return new CosmosDBEnumerableBuilder(configProvider); } diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBHostBuilderExtensionsTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBHostBuilderExtensionsTests.cs index fc1af5cf6..0086d3f46 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBHostBuilderExtensionsTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBHostBuilderExtensionsTests.cs @@ -3,7 +3,10 @@ using System; using System.Collections.Generic; -using Microsoft.Azure.Documents.Client; +using System.IO; +using System.Linq; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.WebJobs.Host.Config; using Microsoft.Extensions.Configuration.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -26,10 +29,7 @@ public void ConfigurationBindsToOptions() { InitialData = new Dictionary { - { "AzureWebJobs:extensions:cosmosDB:Protocol", "Tcp" }, - { "AzureWebJobs:extensions:CosmosDB:LeaseOptions:leaseRenewInterval", "11:11:11" }, - { "AzureWebJobs:extensions:CosmosDB:LeaseOptions:LeasePrefix", "pre1" }, - { "AzureWebJobs:extensions:cosmosdb:leaseoptions:CheckpointFrequency:ProcessedDocumentCount", "1234" } + { "AzureWebJobs:extensions:cosmosDB:ConnectionMode", "Direct" } } }; @@ -39,19 +39,79 @@ public void ConfigurationBindsToOptions() { builder.AddCosmosDB(); }) + .Build(); + + var options = host.Services.GetService>().Value; + + Assert.Equal(ConnectionMode.Direct, options.ConnectionMode); + } + + [Fact] + public void ConfigurationBindsToOptions_WithConfigureServices() + { + IHost host = new HostBuilder() + .ConfigureWebJobs(builder => + { + builder.AddCosmosDB(); + }) .ConfigureServices(s => { // Verifies that you can modify the bound options - s.Configure(o => o.LeaseOptions.LeasePrefix = "pre2"); + s.Configure(o => o.ConnectionMode = ConnectionMode.Direct); }) .Build(); var options = host.Services.GetService>().Value; - Assert.Equal(Protocol.Tcp, options.Protocol); - Assert.Equal(TimeSpan.Parse("11:11:11"), options.LeaseOptions.LeaseRenewInterval); - Assert.Equal("pre2", options.LeaseOptions.LeasePrefix); - Assert.Equal(1234, options.LeaseOptions.CheckpointFrequency.ProcessedDocumentCount); + Assert.Equal(ConnectionMode.Direct, options.ConnectionMode); + } + + [Fact] + public void ConfigurationBindsToOptions_WithSerializer() + { + CustomFactory customFactory = new CustomFactory(); + IHost host = new HostBuilder() + .ConfigureWebJobs(builder => + { + builder.AddCosmosDB(); + }) + .ConfigureServices(s => + { + s.AddSingleton(customFactory); + }) + .Build(); + + var extensionConfig = host.Services.GetServices().Single(); + Assert.NotNull(extensionConfig); + Assert.IsType(extensionConfig); + + CosmosDBExtensionConfigProvider cosmosDBExtensionConfigProvider = (CosmosDBExtensionConfigProvider)extensionConfig; + CosmosClient dummyClient = cosmosDBExtensionConfigProvider.GetService("AccountEndpoint=https://someuri;AccountKey=c29tZV9rZXk=;"); + Assert.True(customFactory.CreateWasCalled); + } + + private class CustomFactory : ICosmosDBSerializerFactory + { + public bool CreateWasCalled { get; private set; } = false; + + public CosmosSerializer CreateSerializer() + { + this.CreateWasCalled = true; + return new CustomSerializer(); + } + } + + private class CustomSerializer : CosmosSerializer + { + public override T FromStream(Stream stream) + { + throw new NotImplementedException(); + } + + public override Stream ToStream(T input) + { + throw new NotImplementedException(); + } } } } diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBItemValueBinderTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBItemValueBinderTests.cs index 970b351c3..764c317c5 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBItemValueBinderTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBItemValueBinderTests.cs @@ -5,8 +5,7 @@ using System.Net; using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Extensions.Tests.Extensions.CosmosDB.Models; using Microsoft.Azure.WebJobs.Host.Bindings; using Moq; @@ -20,7 +19,6 @@ public class CosmosDBItemValueBinderTests private const string DatabaseName = "ItemDb"; private const string CollectionName = "ItemCollection"; private const string Id = "abc123"; - private readonly Uri _expectedUri = UriFactory.CreateDocumentUri(DatabaseName, CollectionName, Id); [Fact] public async Task GetValueAsync_JObject_QueriesItem_WithPartitionKey() @@ -28,10 +26,22 @@ public async Task GetValueAsync_JObject_QueriesItem_WithPartitionKey() // Arrange string partitionKey = "partitionKey"; string partitionKeyValue = string.Format("[\"{0}\"]", partitionKey); - IValueBinder binder = CreateBinder(out Mock mockService, partitionKey); + IValueBinder binder = CreateBinder(out Mock mockService, partitionKey); + + var mockContainer = new Mock(MockBehavior.Strict); + mockService - .Setup(m => m.ReadDocumentAsync(_expectedUri, It.Is(r => r.PartitionKey.ToString() == partitionKeyValue))) - .ReturnsAsync(new Document()); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(new Item()); + + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(i => i == Id), It.Is(r => r.ToString() == partitionKeyValue), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockResponse.Object); // Act var value = (await binder.GetValueAsync()) as Item; @@ -45,10 +55,22 @@ public async Task GetValueAsync_JObject_QueriesItem_WithPartitionKey() public async Task GetValueAsync_JObject_QueriesItem() { // Arrange - IValueBinder binder = CreateBinder(out Mock mockService); + IValueBinder binder = CreateBinder(out Mock mockService); + + var mockContainer = new Mock(MockBehavior.Strict); + mockService - .Setup(m => m.ReadDocumentAsync(_expectedUri, null)) - .ReturnsAsync(new Document()); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(new Item()); + + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(i => i == Id), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockResponse.Object); // Act var value = (await binder.GetValueAsync()) as Item; @@ -62,9 +84,16 @@ public async Task GetValueAsync_JObject_QueriesItem() public async Task GetValueAsync_DoesNotThrow_WhenResponseIsNotFound() { // Arrange - IValueBinder binder = CreateBinder(out Mock mockService); + IValueBinder binder = CreateBinder(out Mock mockService); + + var mockContainer = new Mock(MockBehavior.Strict); + mockService - .Setup(m => m.ReadDocumentAsync(_expectedUri, null)) + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(i => i == Id), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(CosmosDBTestUtility.CreateDocumentClientException(HttpStatusCode.NotFound)); // Act @@ -79,14 +108,21 @@ public async Task GetValueAsync_DoesNotThrow_WhenResponseIsNotFound() public async Task GetValueAsync_Throws_WhenErrorResponse() { // Arrange - IValueBinder binder = CreateBinder(out Mock mockService); + IValueBinder binder = CreateBinder(out Mock mockService); + + var mockContainer = new Mock(MockBehavior.Strict); + mockService - .Setup(m => m.ReadDocumentAsync(_expectedUri, null)) + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(i => i == Id), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(CosmosDBTestUtility.CreateDocumentClientException(HttpStatusCode.ServiceUnavailable)); // Act // TODO: Fix this up so it exposes the real exception - var ex = await Assert.ThrowsAsync(() => binder.GetValueAsync()); + var ex = await Assert.ThrowsAsync(() => binder.GetValueAsync()); // Assert Assert.Equal(HttpStatusCode.ServiceUnavailable, ex.StatusCode); @@ -121,7 +157,7 @@ public void TryGetId_CaseSensitive(string idKey, bool expected) public async Task SetAsync_Updates_IfPropertyChanges() { // Arrange - var mockService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); Item original = new Item { @@ -135,9 +171,21 @@ public async Task SetAsync_Updates_IfPropertyChanges() Text = "goodbye" }; + var mockContainer = new Mock(MockBehavior.Strict); + + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(new Item()); + + mockContainer + .Setup(m => m.ReplaceItemAsync(It.Is(it => it.Text == updated.Text), It.Is(i => i == updated.Id), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockResponse.Object); + mockService - .Setup(m => m.ReplaceDocumentAsync(_expectedUri, updated)) - .ReturnsAsync(new Document()); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + CosmosDBAttribute attribute = new CosmosDBAttribute(DatabaseName, CollectionName) { @@ -163,7 +211,7 @@ public async Task SetAsync_Updates_IfPropertyChanges() public async Task SetAsync_Poco_SkipsUpdate_IfSame() { // Arrange - var mockService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); Item original = new Item { @@ -203,7 +251,7 @@ public async Task SetAsync_Poco_SkipsUpdate_IfSame() public async Task SetAsync_Poco_Throws_IfIdChanges() { // Arrange - var mockService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); Item original = new Item { @@ -233,54 +281,67 @@ public async Task SetAsync_Poco_Throws_IfIdChanges() () => CosmosDBItemValueBinder.SetValueInternalAsync(originalJson, updated, context)); // Assert - Assert.Equal("Cannot update the 'Id' property.", ex.Message); + Assert.Equal("Cannot update the 'id' property.", ex.Message); mockService.Verify(); } [Fact] public async Task GetAsync_SetAsync_DoesNotUpdate_IfUnchanged_Poco() { - await TestGetThenSet(); + Item newDocument = new Item + { + Id = Guid.NewGuid().ToString() + }; + newDocument.Text = "some text"; + + await TestGetThenSet(newDocument); } [Fact] public async Task GetAsync_SetAsync_DoesNotUpdate_IfUnchanged_JObject() { - await TestGetThenSet(); - } + JObject newDocument = new JObject(); + newDocument["id"] = Guid.NewGuid().ToString(); + newDocument["Text"] = "some text"; - [Fact] - public async Task GetAsync_SetAsync_DoesNotUpdate_IfUnchanged_Document() - { - await TestGetThenSet(); + await TestGetThenSet(newDocument); } [Fact] public async Task GetAsync_SetAsync_DoesNotUpdate_IfUnchanged_String() { - await TestGetThenSet(); + JObject newDocument = new JObject(); + newDocument["id"] = Guid.NewGuid().ToString(); + newDocument["Text"] = "some text"; + await TestGetThenSet(newDocument); } [Fact] public async Task GetAsync_SetAsync_DoesNotUpdate_IfUnchanged_Dynamic() { - await TestGetThenSet(); + await TestGetThenSet(new { id = Guid.NewGuid().ToString(), Text = "some text" }); } - private async Task TestGetThenSet() + private async Task TestGetThenSet(TToRead newDocument) where T : class { // Arrange - Document newDocument = new Document - { - Id = Guid.NewGuid().ToString() - }; - newDocument.SetPropertyValue("text", "some text"); + IValueBinder binder = CreateBinder(out Mock mockService); + + var mockContainer = new Mock(MockBehavior.Strict); - IValueBinder binder = CreateBinder(out Mock mockService); mockService - .Setup(m => m.ReadDocumentAsync(_expectedUri, null)) - .ReturnsAsync(newDocument); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(newDocument); + + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(i => i == Id), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockResponse.Object); // Act // get, then immediately set with no changes @@ -292,10 +353,10 @@ private async Task TestGetThenSet() mockService.Verify(); } - private static CosmosDBItemValueBinder CreateBinder(out Mock mockService, string partitionKey = null) + private static CosmosDBItemValueBinder CreateBinder(out Mock mockService, string partitionKey = null) where T : class { - mockService = new Mock(MockBehavior.Strict); + mockService = new Mock(MockBehavior.Strict); CosmosDBAttribute attribute = new CosmosDBAttribute(DatabaseName, CollectionName) { diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBMockEndToEndTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBMockEndToEndTests.cs index b77bb0757..b932f42df 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBMockEndToEndTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBMockEndToEndTests.cs @@ -4,9 +4,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Extensions.Tests.Common; using Microsoft.Azure.WebJobs.Extensions.Tests.Extensions.CosmosDB.Models; using Microsoft.Azure.WebJobs.Host; @@ -42,30 +42,50 @@ public CosmosDBMockEndToEndTests() public async Task OutputBindings() { // Arrange - var serviceMock = new Mock(MockBehavior.Strict); + var serviceMock = new Mock(MockBehavior.Strict); + + var mockContainer = new Mock(MockBehavior.Strict); + serviceMock - .Setup(m => m.UpsertDocumentAsync(It.IsAny(), It.IsAny())) - .Returns((uri, item) => + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + mockContainer + .Setup(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((object item, PartitionKey? partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => + { + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(item); + + return mockResponse.Object; + }); + + mockContainer + .Setup(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((JObject item, PartitionKey? partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => { - // Simulate what DocumentClient does. This will throw an error if a string - // is directly passed as the item. We can't use DocumentClient directly for this - // because it requires a real connection, but we're mocking here. - JObject jObject = JObject.FromObject(item); + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(item); - return Task.FromResult(new Document()); + return mockResponse.Object; }); var factoryMock = new Mock(MockBehavior.Strict); factoryMock - .Setup(f => f.CreateService(ConfigConnStr, It.IsAny(), It.IsAny())) + .Setup(f => f.CreateService(ConfigConnStr, It.IsAny())) .Returns(serviceMock.Object); //Act await RunTestAsync("Outputs", factoryMock.Object); // Assert - factoryMock.Verify(f => f.CreateService(ConfigConnStr, It.IsAny(), It.IsAny()), Times.Once()); - serviceMock.Verify(m => m.UpsertDocumentAsync(It.IsAny(), It.IsAny()), Times.Exactly(8)); + factoryMock.Verify(f => f.CreateService(ConfigConnStr, It.IsAny()), Times.Once()); + mockContainer.Verify(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(7)); + mockContainer.Verify(m => m.UpsertItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); Assert.Equal("Outputs", _loggerProvider.GetAllUserLogMessages().Single().FormattedMessage); } @@ -75,15 +95,15 @@ public async Task ClientBinding() // Arrange var factoryMock = new Mock(MockBehavior.Strict); factoryMock - .Setup(f => f.CreateService(DefaultConnStr, It.IsAny(), It.IsAny())) - .Returns((connectionString, connectionPolicy, useDefaultDeserialization) => new CosmosDBService(connectionString, connectionPolicy, useDefaultDeserialization)); + .Setup(f => f.CreateService(DefaultConnStr, It.IsAny())) + .Returns((connectionString, connectionPolicy) => new CosmosClient(connectionString, connectionPolicy)); // Act // Also verify that this falls back to the default by setting the config connection string to null await RunTestAsync("Client", factoryMock.Object, configConnectionString: null); //Assert - factoryMock.Verify(f => f.CreateService(DefaultConnStr, It.IsAny(), It.IsAny()), Times.Once()); + factoryMock.Verify(f => f.CreateService(DefaultConnStr, It.IsAny()), Times.Once()); Assert.Equal("Client", _loggerProvider.GetAllUserLogMessages().Single().FormattedMessage); } @@ -96,84 +116,162 @@ public async Task InputBindings() string item3Id = "docid3"; string item4Id = "docid4"; string item5Id = "docid5"; - Uri item1Uri = UriFactory.CreateDocumentUri(DatabaseName, CollectionName, item1Id); - Uri item2Uri = UriFactory.CreateDocumentUri(DatabaseName, CollectionName, item2Id); - Uri item3Uri = UriFactory.CreateDocumentUri(DatabaseName, CollectionName, item3Id); - Uri item4Uri = UriFactory.CreateDocumentUri("ResolvedDatabase", "ResolvedCollection", item4Id); - Uri item5Uri = UriFactory.CreateDocumentUri(DatabaseName, CollectionName, item5Id); - Uri collectionUri = UriFactory.CreateDocumentCollectionUri(DatabaseName, CollectionName); string options2 = string.Format("[\"{0}\"]", item1Id); // this comes from the trigger string options3 = "[\"partkey3\"]"; - var serviceMock = new Mock(MockBehavior.Strict); + var serviceMock = new Mock(MockBehavior.Strict); - serviceMock - .Setup(m => m.ReadDocumentAsync(item1Uri, null)) - .ReturnsAsync(new Document { Id = item1Id }); + var mockContainer = new Mock(MockBehavior.Strict); serviceMock - .Setup(m => m.ReadDocumentAsync(item2Uri, It.Is(r => r.PartitionKey.ToString() == options2))) - .ReturnsAsync(new Document { Id = item2Id }); + .Setup(m => m.GetContainer(It.Is(d => d == "ResolvedDatabase"), It.Is(c => c == "ResolvedCollection"))) + .Returns(mockContainer.Object); serviceMock - .Setup(m => m.ReadDocumentAsync(item3Uri, It.Is(r => r.PartitionKey.ToString() == options3))) - .ReturnsAsync(new Document { Id = item3Id }); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); - serviceMock - .Setup(m => m.ReadDocumentAsync(item4Uri, null)) - .ReturnsAsync(new Document { Id = item4Id }); + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(id => id == item1Id), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string id, PartitionKey partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => + { + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(new Item { Id = item1Id }); - serviceMock - .Setup(m => m.ReadDocumentAsync(item5Uri, null)) - .ReturnsAsync(new Document { Id = item5Id }); + return mockResponse.Object; + }); - serviceMock - .Setup(m => m.ExecuteNextAsync( - collectionUri, - It.Is((s) => - s.QueryText == "some query" && - s.Parameters.Count() == 0), - null)) - .ReturnsAsync(new DocumentQueryResponse()); + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(id => id == item2Id), It.Is(pk => pk.ToString() == options2), It.IsAny(), It.IsAny())) + .ReturnsAsync((string id, PartitionKey partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => + { + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(new Item { Id = item2Id }); - serviceMock - .Setup(m => m.ExecuteNextAsync( - collectionUri, - It.Is((s) => - s.QueryText == "some ResolvedQuery with '@QueueTrigger' replacements" && - s.Parameters.Count() == 1 && - s.Parameters[0].Name == "@QueueTrigger" && - s.Parameters[0].Value.ToString() == "docid1"), - null)) - .ReturnsAsync(new DocumentQueryResponse()); + return mockResponse.Object; + }); - serviceMock - .Setup(m => m.ExecuteNextAsync( - collectionUri, - It.Is((s) => - s.QueryText == null && - s.Parameters.Count() == 0), - null)) - .ReturnsAsync(new DocumentQueryResponse()); + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(id => id == item3Id), It.Is(pk => pk.ToString() == options3), It.IsAny(), It.IsAny())) + .ReturnsAsync((string id, PartitionKey partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => + { + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(new Item { Id = item3Id }); + + return mockResponse.Object; + }); + + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(id => id == item4Id), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string id, PartitionKey partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => + { + Mock> mockResponse = new Mock>(); + JObject item = new JObject(); + item["Id"] = item4Id; + mockResponse + .Setup(m => m.Resource) + .Returns(item); + + return mockResponse.Object; + }); + + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(id => id == item5Id), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string id, PartitionKey partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => + { + Mock> mockResponse = new Mock>(); + JObject item = new JObject(); + item["Id"] = item5Id; + mockResponse + .Setup(m => m.Resource) + .Returns(item); + + return mockResponse.Object; + }); + + mockContainer + .Setup(m => m.GetItemQueryIterator( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((QueryDefinition a, string b, QueryRequestOptions c) => + { + Mock> mockIterator = new Mock>(); + mockIterator.SetupSequence(m => m.HasMoreResults).Returns(true).Returns(false); + mockIterator + .Setup(m => m.ReadNextAsync(It.IsAny())) + .ReturnsAsync(Mock.Of>()); + + return mockIterator.Object; + }); + + mockContainer + .Setup(m => m.GetItemQueryIterator( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((QueryDefinition a, string b, QueryRequestOptions c) => + { + Mock> mockIterator = new Mock>(); + mockIterator.SetupSequence(m => m.HasMoreResults).Returns(true).Returns(false); + mockIterator + .Setup(m => m.ReadNextAsync(It.IsAny())) + .ReturnsAsync(Mock.Of>()); + + return mockIterator.Object; + }); // We only expect item2 to be updated - serviceMock - .Setup(m => m.ReplaceDocumentAsync(item2Uri, It.Is(d => ((Document)d).Id == item2Id))) - .ReturnsAsync(new Document()); + mockContainer + .Setup(m => m.ReplaceItemAsync(It.Is(i => i.Id == item2Id), It.Is(id => id == item2Id), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Item item, string id, PartitionKey? partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => + { + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(item); + + return mockResponse.Object; + }); + var factoryMock = new Mock(MockBehavior.Strict); factoryMock - .Setup(f => f.CreateService(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(f => f.CreateService(It.IsAny(), It.IsAny())) .Returns(serviceMock.Object); // Act await RunTestAsync(nameof(CosmosDBEndToEndFunctions.Inputs), factoryMock.Object, item1Id); // Assert - factoryMock.Verify(f => f.CreateService(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + factoryMock.Verify(f => f.CreateService(It.IsAny(), It.IsAny()), Times.Once()); Assert.Equal("Inputs", _loggerProvider.GetAllUserLogMessages().Single().FormattedMessage); serviceMock.VerifyAll(); + + mockContainer + .Verify(m => m.GetItemQueryIterator( + It.Is(qd => qd != null && (qd.QueryText == "some query" || qd.QueryText == "some ResolvedQuery with '@QueueTrigger' replacements")), + It.IsAny(), + It.Is(ro => !ro.PartitionKey.HasValue)), Times.Exactly(2)); + + mockContainer + .Verify(m => m.GetItemQueryIterator( + It.Is(qd => qd == null), + It.IsAny(), + It.Is(ro => ro.PartitionKey == new PartitionKey(item1Id))), Times.Once); + + mockContainer + .Verify(m => m.GetItemQueryIterator( + It.Is(qd => qd == null), + It.IsAny(), + It.Is(ro => !ro.PartitionKey.HasValue)), Times.Once); } [Fact] @@ -181,19 +279,32 @@ public async Task TriggerObject() { // Arrange string itemId = "docid1"; - Uri itemUri = UriFactory.CreateDocumentUri(DatabaseName, CollectionName, itemId); string key = "[\"partkey1\"]"; - var serviceMock = new Mock(MockBehavior.Strict); + var serviceMock = new Mock(MockBehavior.Strict); + + var mockContainer = new Mock(MockBehavior.Strict); serviceMock - .Setup(m => m.ReadDocumentAsync(itemUri, It.Is(r => r.PartitionKey.ToString() == key))) - .ReturnsAsync(new Document { Id = itemId }); + .Setup(m => m.GetContainer(It.Is(d => d == DatabaseName), It.Is(c => c == CollectionName))) + .Returns(mockContainer.Object); + + mockContainer + .Setup(m => m.ReadItemAsync(It.Is(id => id == itemId), It.Is(pk => pk.ToString() == key), It.IsAny(), It.IsAny())) + .ReturnsAsync((string id, PartitionKey partitionKey, ItemRequestOptions itemRequestOptions, CancellationToken cancellationToken) => + { + Mock> mockResponse = new Mock>(); + mockResponse + .Setup(m => m.Resource) + .Returns(new { id = itemId }); + + return mockResponse.Object; + }); var factoryMock = new Mock(MockBehavior.Strict); factoryMock - .Setup(f => f.CreateService(AttributeConnStr, It.IsAny(), It.IsAny())) + .Setup(f => f.CreateService(AttributeConnStr, It.IsAny())) .Returns(serviceMock.Object); var jobject = JObject.FromObject(new QueueData { DocumentId = "docid1", PartitionKey = "partkey1" }); @@ -202,7 +313,7 @@ public async Task TriggerObject() await RunTestAsync(nameof(CosmosDBEndToEndFunctions.TriggerObject), factoryMock.Object, jobject.ToString()); // Assert - factoryMock.Verify(f => f.CreateService(AttributeConnStr, It.IsAny(), It.IsAny()), Times.Once()); + factoryMock.Verify(f => f.CreateService(AttributeConnStr, It.IsAny()), Times.Once()); Assert.Equal("TriggerObject", _loggerProvider.GetAllUserLogMessages().Single().FormattedMessage); } @@ -360,10 +471,10 @@ public static void Outputs( newItemString = "{}"; - arrayItem = new Document[] + arrayItem = new Item[] { - new Document(), - new Document() + new Item(), + new Item() }; Task.WaitAll(new[] @@ -380,7 +491,7 @@ public static void Outputs( [NoAutomaticTrigger] public static void Client( - [CosmosDB] DocumentClient client, + [CosmosDB] CosmosClient client, TraceWriter trace) { Assert.NotNull(client); @@ -391,14 +502,15 @@ public static void Client( [NoAutomaticTrigger] public static void Inputs( [QueueTrigger("fakequeue1")] string triggerData, - [CosmosDB(DatabaseName, CollectionName, Id = "{QueueTrigger}")] dynamic item1, - [CosmosDB(DatabaseName, CollectionName, Id = "docid2", PartitionKey = "{QueueTrigger}")] dynamic item2, + [CosmosDB(DatabaseName, CollectionName, Id = "{QueueTrigger}")] Item item1, + [CosmosDB(DatabaseName, CollectionName, Id = "docid2", PartitionKey = "{QueueTrigger}")] Item item2, [CosmosDB(DatabaseName, CollectionName, Id = "docid3", PartitionKey = "partkey3")] Item item3, [CosmosDB("%Database%", "%Collection%", Id = "docid4")] JObject item4, [CosmosDB(DatabaseName, CollectionName, Id = "docid5")] string item5, [CosmosDB(DatabaseName, CollectionName, SqlQuery = "some query")] IEnumerable query1, [CosmosDB(DatabaseName, CollectionName, SqlQuery = "some %Query% with '{QueueTrigger}' replacements")] IEnumerable query2, [CosmosDB(DatabaseName, CollectionName)] JArray query3, + [CosmosDB(DatabaseName, CollectionName, PartitionKey = "{QueueTrigger}")] IEnumerable query4, TraceWriter trace) { Assert.NotNull(item1); @@ -409,9 +521,10 @@ public static void Inputs( Assert.NotNull(query1); Assert.NotNull(query2); Assert.NotNull(query3); + Assert.NotNull(query4); // add some value to item2 - item2.text = "changed"; + item2.Text = "changed"; trace.Warning("Inputs"); } @@ -432,7 +545,7 @@ private class NoConnectionString { [NoAutomaticTrigger] public static void Broken( - [CosmosDB] DocumentClient client) + [CosmosDB] CosmosClient client) { } } diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBSqlResolutionPolicyTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBSqlResolutionPolicyTests.cs index b31f0599e..1f1c1dc0f 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBSqlResolutionPolicyTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBSqlResolutionPolicyTests.cs @@ -14,10 +14,11 @@ public class CosmosDBSqlResolutionPolicyTests public void TemplateBind_MultipleParameters() { // Arrange + string query = "SELECT * FROM c WHERE c.id = {foo} AND c.value = {bar}"; PropertyInfo propInfo = null; - CosmosDBAttribute resolvedAttribute = new CosmosDBAttribute(); + CosmosDBAttribute resolvedAttribute = new CosmosDBAttribute() { SqlQuery = query }; BindingTemplate bindingTemplate = - BindingTemplate.FromString("SELECT * FROM c WHERE c.id = {foo} AND c.value = {bar}"); + BindingTemplate.FromString(query); Dictionary bindingData = new Dictionary(); bindingData.Add("foo", "1234"); bindingData.Add("bar", "5678"); @@ -27,8 +28,9 @@ public void TemplateBind_MultipleParameters() string result = policy.TemplateBind(propInfo, resolvedAttribute, bindingTemplate, bindingData); // Assert - Assert.Single(resolvedAttribute.SqlQueryParameters, p => p.Name == "@foo" && p.Value.ToString() == "1234"); - Assert.Single(resolvedAttribute.SqlQueryParameters, p => p.Name == "@bar" && p.Value.ToString() == "5678"); + Assert.Single(resolvedAttribute.SqlQueryParameters, p => p.Item1 == "@foo" && p.Item2.ToString() == "1234"); + Assert.Single(resolvedAttribute.SqlQueryParameters, p => p.Item1 == "@bar" && p.Item2.ToString() == "5678"); + Assert.Equal("SELECT * FROM c WHERE c.id = @foo AND c.value = @bar", result); } @@ -36,10 +38,11 @@ public void TemplateBind_MultipleParameters() public void TemplateBind_DuplicateParameters() { // Arrange + string query = "SELECT * FROM c WHERE c.id = {foo} AND c.value = {foo}"; PropertyInfo propInfo = null; - CosmosDBAttribute resolvedAttribute = new CosmosDBAttribute(); + CosmosDBAttribute resolvedAttribute = new CosmosDBAttribute() { SqlQuery = query }; BindingTemplate bindingTemplate = - BindingTemplate.FromString("SELECT * FROM c WHERE c.id = {foo} AND c.value = {foo}"); + BindingTemplate.FromString(query); Dictionary bindingData = new Dictionary(); bindingData.Add("foo", "1234"); CosmosDBSqlResolutionPolicy policy = new CosmosDBSqlResolutionPolicy(); @@ -48,7 +51,7 @@ public void TemplateBind_DuplicateParameters() string result = policy.TemplateBind(propInfo, resolvedAttribute, bindingTemplate, bindingData); // Assert - Assert.Single(resolvedAttribute.SqlQueryParameters, p => p.Name == "@foo" && p.Value.ToString() == "1234"); + Assert.Single(resolvedAttribute.SqlQueryParameters, p => p.Item1 == "@foo" && p.Item2.ToString() == "1234"); Assert.Equal("SELECT * FROM c WHERE c.id = @foo AND c.value = @foo", result); } } diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBTestUtility.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBTestUtility.cs index 988669125..a0e94f135 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBTestUtility.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBTestUtility.cs @@ -3,13 +3,12 @@ using System; using System.Collections.Generic; -using System.Collections.Specialized; using System.Linq; using System.Linq.Expressions; using System.Net; using System.Reflection; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using System.Threading; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Extensions.Tests.Extensions.CosmosDB.Models; using Microsoft.Azure.WebJobs.Host.Bindings; using Microsoft.Extensions.Configuration; @@ -24,7 +23,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB.Tests internal static class CosmosDBTestUtility { public const string DatabaseName = "ItemDB"; - public const string CollectionName = "ItemCollection"; + public const string ContainerName = "ItemCollection"; // Runs the standard options pipeline for initialization public static IOptions InitializeOptions(string defaultConnectionString, string optionsConnectionString) @@ -60,45 +59,74 @@ public static IOptions InitializeOptions(string defaultConnecti return builder.Build().Services.GetService>(); } - public static void SetupCollectionMock(Mock mockService, string partitionKeyPath = null, int throughput = 0) + public static Mock SetupCollectionMock(Mock mockService, Mock mockDatabase, string partitionKeyPath = null, int throughput = 0) { - Uri databaseUri = UriFactory.CreateDatabaseUri(DatabaseName); + var mockContainer = new Mock(MockBehavior.Strict); - var expectedPaths = new List(); - if (!string.IsNullOrEmpty(partitionKeyPath)) - { - expectedPaths.Add(partitionKeyPath); - } + mockService + .Setup(m => m.GetDatabase(It.Is(d => d == DatabaseName))) + .Returns(mockDatabase.Object); + + var response = new Mock(); + response + .Setup(m => m.Container) + .Returns(mockContainer.Object); + + mockDatabase + .Setup(db => db.GetContainer(It.Is(i => i == ContainerName))) + .Returns(mockContainer.Object); + + mockContainer + .Setup(c => c.ReadContainerAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new CosmosException("test", HttpStatusCode.NotFound, 0, string.Empty, 0)); if (throughput == 0) { - mockService - .Setup(m => m.CreateDocumentCollectionIfNotExistsAsync(databaseUri, - It.Is(d => d.Id == CollectionName && Enumerable.SequenceEqual(d.PartitionKey.Paths, expectedPaths)), - null)) - .ReturnsAsync(new DocumentCollection()); + mockDatabase + .Setup(m => m.CreateContainerAsync(It.Is(i => i == ContainerName), + It.Is(p => p == partitionKeyPath), + It.Is(t => t == null), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(response.Object); } else { - mockService - .Setup(m => m.CreateDocumentCollectionIfNotExistsAsync(databaseUri, - It.Is(d => d.Id == CollectionName && Enumerable.SequenceEqual(d.PartitionKey.Paths, expectedPaths)), - It.Is(r => r.OfferThroughput == throughput))) - .ReturnsAsync(new DocumentCollection()); + mockDatabase + .Setup(m => m.CreateContainerAsync(It.Is(i => i == ContainerName), + It.Is(p => p == partitionKeyPath), + It.Is(t => t == throughput), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(response.Object); } + + return mockContainer; } - public static void SetupDatabaseMock(Mock mockService) + public static Mock SetupDatabaseMock(Mock mockService) { + Mock database = new Mock(); + database + .Setup(m => m.Id) + .Returns(DatabaseName); + + Mock response = new Mock(); + response + .Setup(m => m.Database) + .Returns(database.Object); + mockService - .Setup(m => m.CreateDatabaseIfNotExistsAsync(It.Is(d => d.Id == DatabaseName))) - .ReturnsAsync(new Database()); + .Setup(m => m.CreateDatabaseIfNotExistsAsync(It.Is(d => d == DatabaseName), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(response.Object); + + return database; } - public static CosmosDBContext CreateContext(ICosmosDBService service, bool createIfNotExists = false, + public static CosmosDBContext CreateContext(CosmosClient service, bool createIfNotExists = false, string partitionKeyPath = null, int throughput = 0) { - CosmosDBAttribute attribute = new CosmosDBAttribute(CosmosDBTestUtility.DatabaseName, CosmosDBTestUtility.CollectionName) + CosmosDBAttribute attribute = new CosmosDBAttribute(CosmosDBTestUtility.DatabaseName, CosmosDBTestUtility.ContainerName) { CreateIfNotExists = createIfNotExists, PartitionKey = partitionKeyPath, @@ -117,16 +145,9 @@ public static IOrderedQueryable AsOrderedQueryable(this IEnumerable< return enumerable.AsQueryable().OrderBy(keySelector); } - public static DocumentClientException CreateDocumentClientException(HttpStatusCode status) + public static CosmosException CreateDocumentClientException(HttpStatusCode status) { - Type t = typeof(DocumentClientException); - - var constructor = t.GetConstructor( - BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic, - null, new[] { typeof(string), typeof(Exception), typeof(HttpStatusCode?), typeof(Uri), typeof(string) }, null); - - object ex = constructor.Invoke(new object[] { string.Empty, new Exception(), status, null, string.Empty }); - return ex as DocumentClientException; + return new CosmosException("error!", status, 0, string.Empty, 0); } public static ParameterInfo GetInputParameter() @@ -186,14 +207,13 @@ private static void OutputParameters( } private static void ItemInputParameters( - [CosmosDB(Id = "abc123")] Document document, [CosmosDB(Id = "abc123")] Item poco, [CosmosDB(Id = "abc123")] object obj) { } private static void ClientInputParameters( - [CosmosDB] DocumentClient client) + [CosmosDB] CosmosClient client) { } } diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBUtilityTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBUtilityTests.cs index 8f2923f3f..1760a1ab1 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBUtilityTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/CosmosDBUtilityTests.cs @@ -2,10 +2,12 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; +using System.IO; using System.Linq; using System.Net; +using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; +using Microsoft.Azure.Cosmos; using Moq; using Xunit; @@ -17,10 +19,9 @@ public class CosmosDBUtilityTests public async Task CreateIfNotExists_DoesNotSetThroughput_IfZero() { // Arrange - var mockService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); var context = CosmosDBTestUtility.CreateContext(mockService.Object, throughput: 0); - CosmosDBTestUtility.SetupDatabaseMock(mockService); - CosmosDBTestUtility.SetupCollectionMock(mockService); + CosmosDBTestUtility.SetupCollectionMock(mockService, CosmosDBTestUtility.SetupDatabaseMock(mockService)); // Act await CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(context); @@ -34,10 +35,9 @@ public async Task CreateIfNotExists_SetsPartitionKey_IfSpecified() { // Arrange string partitionKeyPath = "partitionKey"; - var mockService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); var context = CosmosDBTestUtility.CreateContext(mockService.Object, partitionKeyPath: partitionKeyPath); - CosmosDBTestUtility.SetupDatabaseMock(mockService); - CosmosDBTestUtility.SetupCollectionMock(mockService, partitionKeyPath); + CosmosDBTestUtility.SetupCollectionMock(mockService, CosmosDBTestUtility.SetupDatabaseMock(mockService), partitionKeyPath); // Act await CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(context); @@ -50,10 +50,9 @@ public async Task CreateIfNotExists_SetsPartitionKey_IfSpecified() public async Task CreateIfNotExist_Succeeds() { // Arrange - var mockService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); CosmosDBContext context = CosmosDBTestUtility.CreateContext(mockService.Object); - CosmosDBTestUtility.SetupDatabaseMock(mockService); - CosmosDBTestUtility.SetupCollectionMock(mockService); + CosmosDBTestUtility.SetupCollectionMock(mockService, CosmosDBTestUtility.SetupDatabaseMock(mockService)); // Act await CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(context); @@ -66,17 +65,17 @@ public async Task CreateIfNotExist_Succeeds() public async Task CreateIfNotExist_Rethrows() { // Arrange - var mockService = new Mock(MockBehavior.Strict); + var mockService = new Mock(MockBehavior.Strict); CosmosDBContext context = CosmosDBTestUtility.CreateContext(mockService.Object); CosmosDBTestUtility.SetupDatabaseMock(mockService); // overwrite the default setup with one that throws mockService - .Setup(m => m.CreateDatabaseIfNotExistsAsync(It.Is(d => d.Id == CosmosDBTestUtility.DatabaseName))) + .Setup(m => m.CreateDatabaseIfNotExistsAsync(It.Is(d => d == CosmosDBTestUtility.DatabaseName), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(CosmosDBTestUtility.CreateDocumentClientException(HttpStatusCode.BadRequest)); // Act - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => CosmosDBUtility.CreateDatabaseAndCollectionIfNotExistAsync(context)); // Assert @@ -117,18 +116,48 @@ public void BuildConnectionPolicy() { // Arrange string preferredLocationsWithEntries = "East US, North Europe,"; - bool useMultiMaster = true; string userAgent = Guid.NewGuid().ToString(); + CosmosSerializer serializer = new CustomSerializer(); + + // Act + var policy = CosmosDBUtility.BuildClientOptions(ConnectionMode.Direct, serializer, preferredLocationsWithEntries, userAgent); + + // Assert + Assert.Equal(userAgent, policy.ApplicationName); + Assert.Equal(ConnectionMode.Direct, policy.ConnectionMode); + Assert.Equal(serializer, policy.Serializer); + Assert.Equal(2, policy.ApplicationPreferredRegions.Count); + } + [Fact] + public void BuildConnectionPolicy_Defaults() + { + // Arrange // Act - var policy = CosmosDBUtility.BuildConnectionPolicy(Documents.Client.ConnectionMode.Direct, Documents.Client.Protocol.Tcp, preferredLocationsWithEntries, useMultiMaster, userAgent); + var policy = CosmosDBUtility.BuildClientOptions( + connectionMode: null, + serializer: null, + preferredLocations: null, + userAgent: null); // Assert - Assert.Equal(userAgent, policy.UserAgentSuffix); - Assert.Equal(useMultiMaster, policy.UseMultipleWriteLocations); - Assert.Equal(Documents.Client.ConnectionMode.Direct, policy.ConnectionMode); - Assert.Equal(Documents.Client.Protocol.Tcp, policy.ConnectionProtocol); - Assert.Equal(2, policy.PreferredLocations.Count); + Assert.Null(policy.ApplicationName); + Assert.Equal(ConnectionMode.Gateway, policy.ConnectionMode); + Assert.Null(policy.Serializer); + Assert.Null(policy.ApplicationPreferredRegions); + } + + private class CustomSerializer : CosmosSerializer + { + public override T FromStream(Stream stream) + { + throw new NotImplementedException(); + } + + public override Stream ToStream(T input) + { + throw new NotImplementedException(); + } } } } diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/TestCosmosDBServiceFactory.cs b/test/WebJobs.Extensions.CosmosDB.Tests/TestCosmosDBServiceFactory.cs index f14309b29..f5502c7ff 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/TestCosmosDBServiceFactory.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/TestCosmosDBServiceFactory.cs @@ -1,20 +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 Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB.Tests { internal class TestCosmosDBServiceFactory : ICosmosDBServiceFactory { - private ICosmosDBService _service; + private CosmosClient _service; - public TestCosmosDBServiceFactory(ICosmosDBService service) + public TestCosmosDBServiceFactory(CosmosClient service) { _service = service; } - public ICosmosDBService CreateService(string connectionString, ConnectionPolicy connectionPolicy, bool useDefaultJsonSerialization) + public CosmosClient CreateService(string connectionString, CosmosClientOptions options) { return _service; } diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBListenerTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBListenerTests.cs index bdadb6342..03f502b5e 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBListenerTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBListenerTests.cs @@ -8,10 +8,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents.ChangeFeedProcessor; -using Microsoft.Azure.Documents.ChangeFeedProcessor.PartitionManagement; -using Microsoft.Azure.Documents.Client; -using Microsoft.Azure.WebJobs.Extensions.CosmosDB.Trigger; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Extensions.Tests.Common; using Microsoft.Azure.WebJobs.Host.Executors; using Microsoft.Azure.WebJobs.Host.Scale; @@ -24,17 +21,18 @@ namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB.Tests.Trigger { public class CosmosDBListenerTests { + private static readonly string DatabaseName = "testDb"; + private static readonly string ContainerName = "testContainer"; + private static readonly string ProcessorName = "theProcessor"; + private readonly TestLoggerProvider _loggerProvider = new TestLoggerProvider(); - private ILoggerFactory _loggerFactory; - private Mock _mockExecutor; - private Mock _mockMonitoredService; - private Mock _mockLeasesService; - private DocumentCollectionInfo _monitoredInfo; - private DocumentCollectionInfo _leasesInfo; - private ChangeFeedProcessorOptions _processorOptions; - private CosmosDBTriggerListener _listener; - private Mock _mockWorkEstimator; - private string _functionId; + private readonly ILoggerFactory _loggerFactory; + private readonly Mock _mockExecutor; + private readonly Mock _monitoredContainer; + private readonly Mock _leasesContainer; + private readonly Mock> _estimatorIterator; + private readonly CosmosDBTriggerListener _listener; + private readonly string _functionId; public CosmosDBListenerTests() { @@ -44,34 +42,54 @@ public CosmosDBListenerTests() _mockExecutor = new Mock(); _functionId = "testfunctionid"; - _mockMonitoredService = new Mock(MockBehavior.Strict); - _mockMonitoredService.Setup(m => m.GetClient()).Returns(new DocumentClient(new Uri("http://someurl"), "c29tZV9rZXk=")); - _monitoredInfo = new DocumentCollectionInfo { Uri = new Uri("http://someurl"), MasterKey = "c29tZV9rZXk=", DatabaseName = "MonitoredDB", CollectionName = "MonitoredCollection" }; + var database = new Mock(MockBehavior.Strict); + database.Setup(d => d.Id).Returns(DatabaseName); + + _monitoredContainer = new Mock(MockBehavior.Strict); + _monitoredContainer.Setup(m => m.Id).Returns(ContainerName); + _monitoredContainer.Setup(m => m.Database).Returns(database.Object); + + _estimatorIterator = new Mock>(); + + Mock estimator = new Mock(); + estimator.Setup(m => m.GetCurrentStateIterator(It.IsAny())) + .Returns(_estimatorIterator.Object); - _mockLeasesService = new Mock(MockBehavior.Strict); - _mockLeasesService.Setup(m => m.GetClient()).Returns(new DocumentClient(new Uri("http://someurl"), "c29tZV9rZXk=")); - _leasesInfo = new DocumentCollectionInfo { Uri = new Uri("http://someurl"), MasterKey = "c29tZV9rZXk=", DatabaseName = "LeasesDB", CollectionName = "LeasesCollection" }; + _leasesContainer = new Mock(MockBehavior.Strict); + _leasesContainer.Setup(m => m.Id).Returns(ContainerName); + _leasesContainer.Setup(m => m.Database).Returns(database.Object); - _processorOptions = new ChangeFeedProcessorOptions(); + _monitoredContainer + .Setup(m => m.GetChangeFeedEstimator(It.Is(s => s == ProcessorName), It.Is(c => c == _leasesContainer.Object))) + .Returns(estimator.Object); - // Mock the work estimator so this doesn't require a CosmosDB instance. - _mockWorkEstimator = new Mock(MockBehavior.Strict); + var attribute = new CosmosDBTriggerAttribute(DatabaseName, ContainerName); - _listener = new CosmosDBTriggerListener(_mockExecutor.Object, _functionId, _monitoredInfo, _leasesInfo, _processorOptions, _mockMonitoredService.Object, _mockLeasesService.Object, _loggerFactory.CreateLogger(), _mockWorkEstimator.Object); + _listener = new CosmosDBTriggerListener(_mockExecutor.Object, _functionId, ProcessorName, _monitoredContainer.Object, _leasesContainer.Object, attribute, _loggerFactory.CreateLogger>()); } [Fact] public void ScaleMonitorDescriptor_ReturnsExpectedValue() { - Assert.Equal($"{_functionId}-cosmosdbtrigger-{_monitoredInfo.DatabaseName}-{_monitoredInfo.CollectionName}".ToLower(), _listener.Descriptor.Id); + Assert.Equal($"{_functionId}-cosmosdbtrigger-{DatabaseName}-{ContainerName}".ToLower(), _listener.Descriptor.Id); } [Fact] public async Task GetMetrics_ReturnsExpectedResult() { - _mockWorkEstimator - .Setup(m => m.GetEstimatedRemainingWorkPerPartitionAsync()) - .Returns(Task.FromResult((IReadOnlyList)new List())); + _estimatorIterator + .SetupSequence(m => m.HasMoreResults) + .Returns(true) + .Returns(false); + + Mock> response = new Mock>(); + response + .Setup(m => m.GetEnumerator()) + .Returns(new List().GetEnumerator()); + + _estimatorIterator + .Setup(m => m.ReadNextAsync(It.IsAny())) + .Returns(Task.FromResult(response.Object)); var metrics = await _listener.GetMetricsAsync(); @@ -79,15 +97,20 @@ public async Task GetMetrics_ReturnsExpectedResult() Assert.Equal(0, metrics.RemainingWork); Assert.NotEqual(default(DateTime), metrics.Timestamp); - _mockWorkEstimator - .Setup(m => m.GetEstimatedRemainingWorkPerPartitionAsync()) - .Returns(Task.FromResult((IReadOnlyList)new List() + _estimatorIterator + .SetupSequence(m => m.HasMoreResults) + .Returns(true) + .Returns(false); + + response + .Setup(m => m.GetEnumerator()) + .Returns(new List() { - new RemainingPartitionWork("a", 5), - new RemainingPartitionWork("b", 5), - new RemainingPartitionWork("c", 5), - new RemainingPartitionWork("d", 5) - })); + new ChangeFeedProcessorState("a", 5, string.Empty), + new ChangeFeedProcessorState("b", 5, string.Empty), + new ChangeFeedProcessorState("c", 5, string.Empty), + new ChangeFeedProcessorState("d", 5, string.Empty) + }.GetEnumerator()); metrics = await _listener.GetMetricsAsync(); @@ -95,6 +118,21 @@ public async Task GetMetrics_ReturnsExpectedResult() Assert.Equal(20, metrics.RemainingWork); Assert.NotEqual(default(DateTime), metrics.Timestamp); + _estimatorIterator + .SetupSequence(m => m.HasMoreResults) + .Returns(true) + .Returns(false); + + response + .Setup(m => m.GetEnumerator()) + .Returns(new List() + { + new ChangeFeedProcessorState("a", 5, string.Empty), + new ChangeFeedProcessorState("b", 5, string.Empty), + new ChangeFeedProcessorState("c", 5, string.Empty), + new ChangeFeedProcessorState("d", 5, string.Empty) + }.GetEnumerator()); + // verify non-generic interface works as expected metrics = (CosmosDBTriggerMetrics)(await ((IScaleMonitor)_listener).GetMetricsAsync()); Assert.Equal(4, metrics.PartitionCount); @@ -106,11 +144,14 @@ public async Task GetMetrics_ReturnsExpectedResult() public async Task GetMetrics_HandlesExceptions() { // Can't test DocumentClientExceptions because they can't be constructed. + _estimatorIterator + .Setup(m => m.HasMoreResults).Returns(true); - // InvalidOperationExceptions - _mockWorkEstimator - .Setup(m => m.GetEstimatedRemainingWorkPerPartitionAsync()) - .Throws(new InvalidOperationException("Resource Not Found")); + _estimatorIterator + .SetupSequence(m => m.ReadNextAsync(It.IsAny())) + .ThrowsAsync(new CosmosException("Resource not found", HttpStatusCode.NotFound, 0, string.Empty, 0)) + .ThrowsAsync(new InvalidOperationException("Unknown")) + .ThrowsAsync(new HttpRequestException("Uh oh", new System.Net.WebException("Uh oh again", WebExceptionStatus.NameResolutionFailure))); var metrics = await _listener.GetMetricsAsync(); @@ -122,22 +163,12 @@ public async Task GetMetrics_HandlesExceptions() Assert.Equal("Please check that the CosmosDB collection and leases collection exist and are listed correctly in Functions config files.", warning.FormattedMessage); _loggerProvider.ClearAllLogMessages(); - // Unknown InvalidOperationExceptions - _mockWorkEstimator - .Setup(m => m.GetEstimatedRemainingWorkPerPartitionAsync()) - .Throws(new InvalidOperationException("Unknown")); - await Assert.ThrowsAsync(async () => await _listener.GetMetricsAsync()); warning = _loggerProvider.GetAllLogMessages().Single(p => p.Level == Microsoft.Extensions.Logging.LogLevel.Warning); Assert.Equal("Unable to handle System.InvalidOperationException: Unknown", warning.FormattedMessage); _loggerProvider.ClearAllLogMessages(); - // HttpRequestExceptions - _mockWorkEstimator - .Setup(m => m.GetEstimatedRemainingWorkPerPartitionAsync()) - .Throws(new HttpRequestException("Uh oh", new System.Net.WebException("Uh oh again", WebExceptionStatus.NameResolutionFailure))); - metrics = await _listener.GetMetricsAsync(); Assert.Equal(0, metrics.PartitionCount); @@ -191,7 +222,7 @@ public void GetScaleStatus_InstancesPerPartitionThresholdExceeded_ReturnsVote_Sc Assert.Equal("WorkerCount (2) > PartitionCount (1).", log.FormattedMessage); log = logs[1]; Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Information, log.Level); - Assert.Equal("Number of instances (2) is too high relative to number of partitions for collection (MonitoredCollection, 1).", log.FormattedMessage); + Assert.Equal($"Number of instances (2) is too high relative to number of partitions for collection ({ContainerName}, 1).", log.FormattedMessage); } [Fact] @@ -221,7 +252,7 @@ public void GetScaleStatus_MessagesPerWorkerThresholdExceeded_ReturnsVote_ScaleO Assert.Equal("RemainingWork (2900) > WorkerCount (1) * 1,000.", log.FormattedMessage); log = logs[1]; Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Information, log.Level); - Assert.Equal("Remaining work for collection (MonitoredCollection, 2900) is too high relative to the number of instances (1).", log.FormattedMessage); + Assert.Equal($"Remaining work for collection ({ContainerName}, 2900) is too high relative to the number of instances (1).", log.FormattedMessage); } [Fact] @@ -248,7 +279,7 @@ public void GetScaleStatus_ConsistentRemainingWork_ReturnsVote_ScaleOut() var logs = _loggerProvider.GetAllLogMessages().ToArray(); var log = logs[0]; Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Information, log.Level); - Assert.Equal("CosmosDB collection 'MonitoredCollection' has documents waiting to be processed.", log.FormattedMessage); + Assert.Equal($"CosmosDB collection '{ContainerName}' has documents waiting to be processed.", log.FormattedMessage); log = logs[1]; Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Information, log.Level); Assert.Equal("There are 1 instances relative to 2 partitions.", log.FormattedMessage); @@ -278,7 +309,7 @@ public void GetScaleStatus_RemainingWorkIncreasing_ReturnsVote_ScaleOut() var logs = _loggerProvider.GetAllLogMessages().ToArray(); var log = logs[0]; Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Information, log.Level); - Assert.Equal("Remaining work is increasing for 'MonitoredCollection'.", log.FormattedMessage); + Assert.Equal($"Remaining work is increasing for '{ContainerName}'.", log.FormattedMessage); } [Fact] @@ -305,13 +336,17 @@ public void GetScaleStatus_RemainingWorkDecreasing_ReturnsVote_ScaleIn() var logs = _loggerProvider.GetAllLogMessages().ToArray(); var log = logs[0]; Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Information, log.Level); - Assert.Equal("Remaining work is decreasing for 'MonitoredCollection'.", log.FormattedMessage); + Assert.Equal($"Remaining work is decreasing for '{ContainerName}'.", log.FormattedMessage); } [Fact] public async Task StartAsync_Retries() { - var listener = new MockListener(_mockExecutor.Object, _functionId, _monitoredInfo, _leasesInfo, _processorOptions, _mockMonitoredService.Object, _mockLeasesService.Object, NullLogger.Instance); + var attribute = new CosmosDBTriggerAttribute("test", "test") { LeaseCollectionPrefix = Guid.NewGuid().ToString() }; + + var mockExecutor = new Mock(); + + var listener = new MockListener(mockExecutor.Object, _monitoredContainer.Object, _leasesContainer.Object, attribute, NullLogger.Instance); // Ensure that we can call StartAsync() multiple times to retry if there is an error. for (int i = 0; i < 3; i++) @@ -324,12 +359,16 @@ public async Task StartAsync_Retries() await listener.StartAsync(CancellationToken.None); } - private class MockListener : CosmosDBTriggerListener + private class MockListener : CosmosDBTriggerListener { private int _retries = 0; - public MockListener(ITriggeredFunctionExecutor executor, string functionId, DocumentCollectionInfo documentCollectionLocation, DocumentCollectionInfo leaseCollectionLocation, ChangeFeedProcessorOptions processorOptions, ICosmosDBService monitoredCosmosDBService, ICosmosDBService leasesCosmosDBService, ILogger logger) - : base(executor, functionId, documentCollectionLocation, leaseCollectionLocation, processorOptions, monitoredCosmosDBService, leasesCosmosDBService, logger) + public MockListener(ITriggeredFunctionExecutor executor, + Container monitoredContainer, + Container leaseContainer, + CosmosDBTriggerAttribute cosmosDBAttribute, + ILogger logger) + : base(executor, Guid.NewGuid().ToString(), string.Empty, monitoredContainer, leaseContainer, cosmosDBAttribute, logger) { } @@ -342,6 +381,10 @@ internal override Task StartProcessorAsync() return Task.CompletedTask; } + + internal override void InitializeBuilder() + { + } } } } \ No newline at end of file diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBTriggerAttributeBindingProviderTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBTriggerAttributeBindingProviderTests.cs index 126321079..2c7a843ca 100644 --- a/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBTriggerAttributeBindingProviderTests.cs +++ b/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBTriggerAttributeBindingProviderTests.cs @@ -7,8 +7,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Cosmos; using Microsoft.Azure.WebJobs.Extensions.CosmosDB; using Microsoft.Azure.WebJobs.Extensions.CosmosDB.Tests; using Microsoft.Azure.WebJobs.Extensions.Tests.Common; @@ -17,7 +16,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; @@ -65,21 +63,11 @@ public static IEnumerable ValidCosmosDBTriggerBindigsPreferredLocation get { return ValidCosmosDBTriggerBindigsPreferredLocations.GetParameters(); } } - public static IEnumerable ValidCosmosDBTriggerBindigsMultiMasterParameters - { - get { return ValidCosmosDBTriggerBindigsMultiMaster.GetParameters(); } - } - - public static IEnumerable ValidCosmosDBTriggerBindingsUseDefaultJsonSerializationParameters - { - get { return ValidCosmosDBTriggerBindingsUseDefaultJsonSerialization.GetParameters(); } - } - [Theory] [MemberData(nameof(InvalidCosmosDBTriggerParameters))] public async Task InvalidParameters_Fail(ParameterInfo parameter) { - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(_emptyConfig, new TestNameResolver(), _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(_emptyConfig, new TestNameResolver(), _options, CreateExtensionConfigProvider(_options), _loggerFactory); InvalidOperationException ex = await Assert.ThrowsAsync(() => provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None))); @@ -98,15 +86,15 @@ public async Task ValidParametersWithEnvironment_Succeed(ParameterInfo parameter }) .Build(); - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); + CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); - Assert.Equal(typeof(IReadOnlyList), binding.TriggerValueType); - Assert.Equal(new Uri("https://fromEnvironment"), binding.DocumentCollectionLocation.Uri); - Assert.Equal(new Uri("https://fromEnvironment"), binding.LeaseCollectionLocation.Uri); - Assert.Empty(binding.DocumentCollectionLocation.ConnectionPolicy.PreferredLocations); - Assert.Empty(binding.LeaseCollectionLocation.ConnectionPolicy.PreferredLocations); + Assert.Equal(typeof(IReadOnlyCollection), binding.TriggerValueType); + Assert.Equal(new Uri("https://fromEnvironment"), binding.MonitoredContainer.Database.Client.Endpoint); + Assert.Equal(new Uri("https://fromEnvironment"), binding.LeaseContainer.Database.Client.Endpoint); + Assert.Null(binding.MonitoredContainer.Database.Client.ClientOptions.ApplicationPreferredRegions); + Assert.Null(binding.LeaseContainer.Database.Client.ClientOptions.ApplicationPreferredRegions); } [Theory] @@ -124,17 +112,17 @@ public async Task ValidParametersWithAppSettings_Succeed(ParameterInfo parameter }) .Build(); - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); + CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); - Assert.Equal(typeof(IReadOnlyList), binding.TriggerValueType); - Assert.Equal(new Uri("https://fromSettings"), binding.DocumentCollectionLocation.Uri); - Assert.Equal(new Uri("https://fromSettings"), binding.LeaseCollectionLocation.Uri); - Assert.Equal("myDatabase", binding.DocumentCollectionLocation.DatabaseName); - Assert.Equal("myCollection", binding.DocumentCollectionLocation.CollectionName); - Assert.Equal("myDatabase", binding.LeaseCollectionLocation.DatabaseName); - Assert.Equal("leases", binding.LeaseCollectionLocation.CollectionName); + Assert.Equal(typeof(IReadOnlyCollection), binding.TriggerValueType); + Assert.Equal(new Uri("https://fromSettings"), binding.MonitoredContainer.Database.Client.Endpoint); + Assert.Equal(new Uri("https://fromSettings"), binding.LeaseContainer.Database.Client.Endpoint); + Assert.Equal("myDatabase", binding.MonitoredContainer.Database.Id); + Assert.Equal("myCollection", binding.MonitoredContainer.Id); + Assert.Equal("myDatabase", binding.LeaseContainer.Database.Id); + Assert.Equal("leases", binding.LeaseContainer.Id); } [Theory] @@ -146,17 +134,18 @@ public async Task ValidCosmosDBTriggerBindigsWithDatabaseAndCollectionSettings_S nameResolver.Values["aDatabase"] = "myDatabase"; nameResolver.Values["aCollection"] = "myCollection"; - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(_emptyConfig, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(_emptyConfig, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); + CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); - Assert.Equal(typeof(IReadOnlyList), binding.TriggerValueType); - Assert.Equal(new Uri("https://fromEnvironment"), binding.DocumentCollectionLocation.Uri); - Assert.Equal(new Uri("https://fromEnvironment"), binding.LeaseCollectionLocation.Uri); - Assert.Equal("myDatabase-test", binding.DocumentCollectionLocation.DatabaseName); - Assert.Equal("myCollection-test", binding.DocumentCollectionLocation.CollectionName); - Assert.Equal("myDatabase-test", binding.LeaseCollectionLocation.DatabaseName); - Assert.Equal("leases", binding.LeaseCollectionLocation.CollectionName); + Assert.Equal(typeof(IReadOnlyCollection), binding.TriggerValueType); + Assert.Equal(new Uri("https://fromEnvironment"), binding.MonitoredContainer.Database.Client.Endpoint); + Assert.Equal(new Uri("https://fromEnvironment"), binding.LeaseContainer.Database.Client.Endpoint); + Assert.Equal("myDatabase-test", binding.MonitoredContainer.Database.Id); + Assert.Equal("myCollection-test", binding.MonitoredContainer.Id); + Assert.Equal("myDatabase-test", binding.LeaseContainer.Database.Id); + Assert.Equal("leases", binding.LeaseContainer.Id); + Assert.Equal(string.Empty, binding.ProcessorName); } [Theory] @@ -174,13 +163,13 @@ public async Task ValidCosmosDBTriggerBindigsDifferentConnections_Succeed(Parame }) .Build(); - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); + CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); - Assert.Equal(typeof(IReadOnlyList), binding.TriggerValueType); - Assert.Equal(new Uri("https://fromSettings"), binding.DocumentCollectionLocation.Uri); - Assert.Equal(new Uri("https://fromSettingsLease"), binding.LeaseCollectionLocation.Uri); + Assert.Equal(typeof(IReadOnlyCollection), binding.TriggerValueType); + Assert.Equal(new Uri("https://fromSettings"), binding.MonitoredContainer.Database.Client.Endpoint); + Assert.Equal(new Uri("https://fromSettingsLease"), binding.LeaseContainer.Database.Client.Endpoint); } [Theory] @@ -196,17 +185,15 @@ public async Task ValidParametersWithEnvironment_ConnectionMode_Succeed(Paramete .Build(); _options.ConnectionMode = ConnectionMode.Direct; - _options.Protocol = Protocol.Tcp; - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); + CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); - Assert.Equal(typeof(IReadOnlyList), binding.TriggerValueType); - Assert.Equal(new Uri("https://fromEnvironment"), binding.DocumentCollectionLocation.Uri); - Assert.Equal(new Uri("https://fromEnvironment"), binding.LeaseCollectionLocation.Uri); - Assert.Equal(ConnectionMode.Direct, binding.DocumentCollectionLocation.ConnectionPolicy.ConnectionMode); - Assert.Equal(Protocol.Tcp, binding.DocumentCollectionLocation.ConnectionPolicy.ConnectionProtocol); + Assert.Equal(typeof(IReadOnlyCollection), binding.TriggerValueType); + Assert.Equal(new Uri("https://fromEnvironment"), binding.MonitoredContainer.Database.Client.Endpoint); + Assert.Equal(new Uri("https://fromEnvironment"), binding.LeaseContainer.Database.Client.Endpoint); + Assert.Equal(ConnectionMode.Direct, binding.LeaseContainer.Database.Client.ClientOptions.ConnectionMode); } [Theory] @@ -216,76 +203,26 @@ public async Task ValidCosmosDBTriggerBindigsPreferredLocationsParameters_Succee var nameResolver = new TestNameResolver(); nameResolver.Values["regions"] = "East US, North Europe,"; - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(_emptyConfig, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(_emptyConfig, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); + CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); - Assert.Equal(2, binding.DocumentCollectionLocation.ConnectionPolicy.PreferredLocations.Count); - Assert.Equal(2, binding.LeaseCollectionLocation.ConnectionPolicy.PreferredLocations.Count); - Assert.Equal("East US", binding.DocumentCollectionLocation.ConnectionPolicy.PreferredLocations[0]); - Assert.Equal("North Europe", binding.DocumentCollectionLocation.ConnectionPolicy.PreferredLocations[1]); - Assert.Equal("East US", binding.LeaseCollectionLocation.ConnectionPolicy.PreferredLocations[0]); - Assert.Equal("North Europe", binding.LeaseCollectionLocation.ConnectionPolicy.PreferredLocations[1]); - Assert.False(binding.DocumentCollectionLocation.ConnectionPolicy.UseMultipleWriteLocations); - Assert.False(binding.LeaseCollectionLocation.ConnectionPolicy.UseMultipleWriteLocations); - } - - [Theory] - [MemberData(nameof(ValidCosmosDBTriggerBindigsMultiMasterParameters))] - public async Task ValidCosmosDBTriggerBindigsMultiMasterParameters_Succeed(ParameterInfo parameter) - { - var nameResolver = new TestNameResolver(); - nameResolver.Values["regions"] = "East US, North Europe,"; - - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(_emptyConfig, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); - - Assert.False(binding.DocumentCollectionLocation.ConnectionPolicy.UseMultipleWriteLocations); - Assert.True(binding.LeaseCollectionLocation.ConnectionPolicy.UseMultipleWriteLocations); - - } - - [Theory] - [MemberData(nameof(ValidCosmosDBTriggerBindingsUseDefaultJsonSerializationParameters))] - public async Task ValidCosmosDBTriggerBindingsUseDefaultJsonSerialization_Succeed(ParameterInfo parameter) - { - var nameResolver = new TestNameResolver(); - - var restoreDefaultSettings = JsonConvert.DefaultSettings; - - var defaultSettingsFetched = false; - JsonConvert.DefaultSettings = () => - { - defaultSettingsFetched = true; - return new JsonSerializerSettings(); - }; - - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(_emptyConfig, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); - - Assert.True(defaultSettingsFetched); - - JsonConvert.DefaultSettings = restoreDefaultSettings; - } - - [Fact] - public void TryAndConvertToDocumentList_Fail() - { - Assert.False(CosmosDBTriggerBinding.TryAndConvertToDocumentList(null, out IReadOnlyList convertedValue)); - Assert.False(CosmosDBTriggerBinding.TryAndConvertToDocumentList("some weird string", out convertedValue)); - Assert.False(CosmosDBTriggerBinding.TryAndConvertToDocumentList(Guid.NewGuid(), out convertedValue)); + Assert.Equal(2, binding.MonitoredContainer.Database.Client.ClientOptions.ApplicationPreferredRegions.Count); + Assert.Equal(2, binding.LeaseContainer.Database.Client.ClientOptions.ApplicationPreferredRegions.Count); + Assert.Equal("East US", binding.MonitoredContainer.Database.Client.ClientOptions.ApplicationPreferredRegions[0]); + Assert.Equal("North Europe", binding.MonitoredContainer.Database.Client.ClientOptions.ApplicationPreferredRegions[1]); + Assert.Equal("East US", binding.LeaseContainer.Database.Client.ClientOptions.ApplicationPreferredRegions[0]); + Assert.Equal("North Europe", binding.LeaseContainer.Database.Client.ClientOptions.ApplicationPreferredRegions[1]); } [Fact] public void ResolveTimeSpanFromMilliseconds_Succeed() { TimeSpan baseTimeSpan = TimeSpan.FromMilliseconds(10); - Assert.Equal(CosmosDBTriggerAttributeBindingProvider.ResolveTimeSpanFromMilliseconds("SomeAttribute", baseTimeSpan, null), baseTimeSpan); + Assert.Equal(CosmosDBTriggerAttributeBindingProvider.ResolveTimeSpanFromMilliseconds("SomeAttribute", baseTimeSpan, null), baseTimeSpan); int otherMilliseconds = 20; TimeSpan otherTimeSpan = TimeSpan.FromMilliseconds(otherMilliseconds); - Assert.Equal(CosmosDBTriggerAttributeBindingProvider.ResolveTimeSpanFromMilliseconds("SomeAttribute", baseTimeSpan, otherMilliseconds), otherTimeSpan); + Assert.Equal(CosmosDBTriggerAttributeBindingProvider.ResolveTimeSpanFromMilliseconds("SomeAttribute", baseTimeSpan, otherMilliseconds), otherTimeSpan); } [Fact] @@ -293,18 +230,7 @@ public void ResolveTimeSpanFromMilliseconds_Fail() { int otherMilliseconds = -1; TimeSpan baseTimeSpan = TimeSpan.FromMilliseconds(10); - Assert.Throws(() => CosmosDBTriggerAttributeBindingProvider.ResolveTimeSpanFromMilliseconds("SomeAttribute", baseTimeSpan, otherMilliseconds)); - } - - [Fact] - public void TryAndConvertToDocumentList_Succeed() - { - Assert.True(CosmosDBTriggerBinding.TryAndConvertToDocumentList("[{\"id\":\"123\"}]", out IReadOnlyList convertedValue)); - Assert.Equal("123", convertedValue[0].Id); - - IReadOnlyList triggerValue = new List() { new Document() { Id = "123" } }; - Assert.True(CosmosDBTriggerBinding.TryAndConvertToDocumentList(triggerValue, out convertedValue)); - Assert.Equal(triggerValue[0].Id, convertedValue[0].Id); + Assert.Throws(() => CosmosDBTriggerAttributeBindingProvider.ResolveTimeSpanFromMilliseconds("SomeAttribute", baseTimeSpan, otherMilliseconds)); } [Theory] @@ -325,17 +251,16 @@ public async Task ValidLeaseHostOptions_Succeed(ParameterInfo parameter) }) .Build(); - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, nameResolver, _options, CreateExtensionConfigProvider(_options), _loggerFactory); - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); + CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); // This test uses the default for ConnectionStringSetting, but overrides LeaseConnectionStringSetting - Assert.Equal(typeof(IReadOnlyList), binding.TriggerValueType); - Assert.Equal(new Uri("https://fromEnvironment"), binding.DocumentCollectionLocation.Uri); - Assert.Equal(new Uri("https://overridden"), binding.LeaseCollectionLocation.Uri); - Assert.Equal("someLeasePrefix", binding.ChangeFeedProcessorOptions.LeasePrefix); - Assert.Null(binding.ChangeFeedProcessorOptions.MaxItemCount); - Assert.False(binding.ChangeFeedProcessorOptions.StartFromBeginning); + Assert.Equal(typeof(IReadOnlyCollection), binding.TriggerValueType); + Assert.Equal(new Uri("https://fromEnvironment"), binding.MonitoredContainer.Database.Client.Endpoint); + Assert.Equal(new Uri("https://overridden"), binding.LeaseContainer.Database.Client.Endpoint); + Assert.Equal("someLeasePrefix", binding.ProcessorName); + Assert.False(binding.CosmosDBAttribute.StartFromBeginning); } [Theory] @@ -350,18 +275,15 @@ public async Task ValidChangeFeedOptions_Succeed(ParameterInfo parameter) }) .Build(); - CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, new TestNameResolver(), _options, CreateExtensionConfigProvider(_options), _loggerFactory); + CosmosDBTriggerAttributeBindingProvider provider = new CosmosDBTriggerAttributeBindingProvider(config, new TestNameResolver(), _options, CreateExtensionConfigProvider(_options), _loggerFactory); - CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); + CosmosDBTriggerBinding binding = (CosmosDBTriggerBinding)await provider.TryCreateAsync(new TriggerBindingProviderContext(parameter, CancellationToken.None)); CosmosDBTriggerAttribute cosmosDBTriggerAttribute = parameter.GetCustomAttribute(inherit: false); - Assert.Equal(typeof(IReadOnlyList), binding.TriggerValueType); - Assert.Equal(new Uri("https://fromSettings"), binding.DocumentCollectionLocation.Uri); - Assert.Equal(new Uri("https://fromSettings"), binding.LeaseCollectionLocation.Uri); - Assert.Equal(cosmosDBTriggerAttribute.MaxItemsPerInvocation, binding.ChangeFeedProcessorOptions.MaxItemCount); - Assert.Equal(cosmosDBTriggerAttribute.StartFromBeginning, binding.ChangeFeedProcessorOptions.StartFromBeginning); - Assert.Equal(cosmosDBTriggerAttribute.StartFromTime, binding.ChangeFeedProcessorOptions.StartTime?.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss%K")); + Assert.Equal(typeof(IReadOnlyCollection), binding.TriggerValueType); + Assert.Equal(new Uri("https://fromSettings"), binding.MonitoredContainer.Database.Client.Endpoint); + Assert.Equal(new Uri("https://fromSettings"), binding.LeaseContainer.Database.Client.Endpoint); } private static ParameterInfo GetFirstParameter(Type type, string methodName) @@ -374,17 +296,17 @@ private static ParameterInfo GetFirstParameter(Type type, string methodName) private static CosmosDBExtensionConfigProvider CreateExtensionConfigProvider(CosmosDBOptions options) { - return new CosmosDBExtensionConfigProvider(new OptionsWrapper(options), new DefaultCosmosDBServiceFactory(), _emptyConfig, new TestNameResolver(), NullLoggerFactory.Instance); + return new CosmosDBExtensionConfigProvider(new OptionsWrapper(options), new DefaultCosmosDBServiceFactory(), new DefaultCosmosDBSerializerFactory(), _emptyConfig, new TestNameResolver(), NullLoggerFactory.Instance); } // These will use the default for ConnectionStringSetting, but override LeaseConnectionStringSetting private static class ValidCosmosDBTriggerBindigsWithLeaseHostOptions { - public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", LeaseConnectionStringSetting = "LeaseConnectionString", LeaseCollectionPrefix = "someLeasePrefix")] IReadOnlyList docs) + public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", LeaseConnectionStringSetting = "LeaseConnectionString", LeaseCollectionPrefix = "someLeasePrefix")] IReadOnlyList docs) { } - public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", LeaseConnectionStringSetting = "LeaseConnectionString", LeaseCollectionPrefix = "%dynamicLeasePrefix%")] IReadOnlyList docs) + public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", LeaseConnectionStringSetting = "LeaseConnectionString", LeaseCollectionPrefix = "%dynamicLeasePrefix%")] IReadOnlyList docs) { } @@ -403,15 +325,15 @@ public static IEnumerable GetParameters() // These will set ConnectionStringSetting, which LeaseConnectionStringSetting should also use by default private static class ValidCosmosDBTriggerBindigsWithChangeFeedOptions { - public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", MaxItemsPerInvocation = 10, StartFromBeginning = true)] IReadOnlyList docs) + public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", MaxItemsPerInvocation = 10, StartFromBeginning = true)] IReadOnlyList docs) { } - public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", MaxItemsPerInvocation = 10, StartFromTime = "2020-11-25T22:36:29Z")] IReadOnlyList docs) + public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", MaxItemsPerInvocation = 10, StartFromTime = "2020-11-25T22:36:29Z")] IReadOnlyList docs) { } - public static void Func3([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", MaxItemsPerInvocation = 10, StartFromBeginning = false, StartFromTime = "2020-11-25T22:36:29Z")] IReadOnlyList docs) + public static void Func3([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", MaxItemsPerInvocation = 10, StartFromBeginning = false, StartFromTime = "2020-11-25T22:36:29Z")] IReadOnlyList docs) { } @@ -423,38 +345,38 @@ public static IEnumerable GetParameters() { new[] { GetFirstParameter(type, "Func1") }, new[] { GetFirstParameter(type, "Func2") }, - new[] { GetFirstParameter(type, "Func3") }, + new[] { GetFirstParameter(type, "Func3") } }; } } private static class InvalidCosmosDBTriggerBindings { - public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "notAConnectionString")] IReadOnlyList docs) + public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "notAConnectionString")] IReadOnlyList docs) { } - public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "notAConnectionString", LeaseConnectionStringSetting = "notAConnectionString", LeaseDatabaseName = "aDatabase", LeaseCollectionName = "aCollection")] IReadOnlyList docs) + public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "notAConnectionString", LeaseConnectionStringSetting = "notAConnectionString", LeaseDatabaseName = "aDatabase", LeaseCollectionName = "aCollection")] IReadOnlyList docs) { } - public static void Func3([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", LeaseConnectionStringSetting = "CosmosDBConnectionString", LeaseDatabaseName = "aDatabase", LeaseCollectionName = "aCollection")] IReadOnlyList docs) + public static void Func3([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", LeaseConnectionStringSetting = "CosmosDBConnectionString", LeaseDatabaseName = "aDatabase", LeaseCollectionName = "aCollection")] IReadOnlyList docs) { } - public static void Func4([CosmosDBTrigger("aDatabase", "leases", ConnectionStringSetting = "CosmosDBConnectionString")] IReadOnlyList docs) + public static void Func4([CosmosDBTrigger("aDatabase", "leases", ConnectionStringSetting = "CosmosDBConnectionString")] IReadOnlyList docs) { } - public static void Func5([CosmosDBTrigger("aDatabase", "leases", ConnectionStringSetting = "CosmosDBConnectionString", StartFromBeginning = true, StartFromTime = "2020-11-25T22:36:29Z")] IReadOnlyList docs) + public static void Func5([CosmosDBTrigger("aDatabase", "leases", ConnectionStringSetting = "CosmosDBConnectionString", StartFromBeginning = true, StartFromTime = "2020-11-25T22:36:29Z")] IReadOnlyList docs) { } - public static void Func6([CosmosDBTrigger("aDatabase", "leases", ConnectionStringSetting = "CosmosDBConnectionString", StartFromTime = "blah")] IReadOnlyList docs) + public static void Func6([CosmosDBTrigger("aDatabase", "leases", ConnectionStringSetting = "CosmosDBConnectionString", StartFromTime = "blah")] IReadOnlyList docs) { } - public static void Func7([CosmosDBTrigger("aDatabase", "leases", ConnectionStringSetting = "CosmosDBConnectionString", StartFromBeginning = true, StartFromTime = "blah")] IReadOnlyList docs) + public static void Func7([CosmosDBTrigger("aDatabase", "leases", ConnectionStringSetting = "CosmosDBConnectionString", StartFromBeginning = true, StartFromTime = "blah")] IReadOnlyList docs) { } @@ -470,18 +392,18 @@ public static IEnumerable GetParameters() new[] { GetFirstParameter(type, "Func4") }, new[] { GetFirstParameter(type, "Func5") }, new[] { GetFirstParameter(type, "Func6") }, - new[] { GetFirstParameter(type, "Func7") }, + new[] { GetFirstParameter(type, "Func7") } }; } } private static class ValidCosmosDBTriggerBindingsWithAppSettings { - public static void Func1([CosmosDBTrigger("%aDatabase%", "%aCollection%", ConnectionStringSetting = "CosmosDBConnectionString")] IReadOnlyList docs) + public static void Func1([CosmosDBTrigger("%aDatabase%", "%aCollection%", ConnectionStringSetting = "CosmosDBConnectionString")] IReadOnlyList docs) { } - public static void Func2([CosmosDBTrigger("%aDatabase%", "%aCollection%", ConnectionStringSetting = "CosmosDBConnectionString", LeaseConnectionStringSetting = "CosmosDBConnectionString", LeaseDatabaseName = "%aDatabase%")] IReadOnlyList docs) + public static void Func2([CosmosDBTrigger("%aDatabase%", "%aCollection%", ConnectionStringSetting = "CosmosDBConnectionString", LeaseConnectionStringSetting = "CosmosDBConnectionString", LeaseDatabaseName = "%aDatabase%")] IReadOnlyList docs) { } @@ -504,11 +426,11 @@ public static IEnumerable GetParameters() private static class ValidCosmosDBTriggerBindigsDifferentConnections { - public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", LeaseConnectionStringSetting = "LeaseCosmosDBConnectionString")] IReadOnlyList docs) + public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", LeaseConnectionStringSetting = "LeaseCosmosDBConnectionString")] IReadOnlyList docs) { } - public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", LeaseConnectionStringSetting = "LeaseCosmosDBConnectionString", LeaseDatabaseName = "aDatabase", LeaseCollectionName = "aLeaseCollection")] IReadOnlyList docs) + public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", ConnectionStringSetting = "CosmosDBConnectionString", LeaseConnectionStringSetting = "LeaseCosmosDBConnectionString", LeaseDatabaseName = "aDatabase", LeaseCollectionName = "aLeaseCollection")] IReadOnlyList docs) { } @@ -526,7 +448,7 @@ public static IEnumerable GetParameters() private static class ValidCosmosDBTriggerBindigsWithEnvironment { - public static void Func1([CosmosDBTrigger("aDatabase", "aCollection")] IReadOnlyList docs) + public static void Func1([CosmosDBTrigger("aDatabase", "aCollection")] IReadOnlyList docs) { } @@ -534,7 +456,7 @@ public static void Func2([CosmosDBTrigger("aDatabase", "aCollection")] JArray do { } - public static void Func3([CosmosDBTrigger("aDatabase", "aCollection", LeaseDatabaseName = "aDatabase", LeaseCollectionName = "aLeaseCollection")] IReadOnlyList docs) + public static void Func3([CosmosDBTrigger("aDatabase", "aCollection", LeaseDatabaseName = "aDatabase", LeaseCollectionName = "aLeaseCollection")] IReadOnlyList docs) { } @@ -553,7 +475,7 @@ public static IEnumerable GetParameters() private static class ValidCosmosDBTriggerBindingsWithDatabaseAndCollectionSettings { - public static void Func1([CosmosDBTrigger("%aDatabase%-test", "%aCollection%-test")] IReadOnlyList docs) + public static void Func1([CosmosDBTrigger("%aDatabase%-test", "%aCollection%-test")] IReadOnlyList docs) { } @@ -561,7 +483,7 @@ public static void Func2([CosmosDBTrigger("%aDatabase%-test", "%aCollection%-tes { } - public static void Func3([CosmosDBTrigger("%aDatabase%-test", "%aCollection%-test", LeaseDatabaseName = "%aDatabase%-test")] IReadOnlyList docs) + public static void Func3([CosmosDBTrigger("%aDatabase%-test", "%aCollection%-test", LeaseDatabaseName = "%aDatabase%-test")] IReadOnlyList docs) { } @@ -580,11 +502,11 @@ public static IEnumerable GetParameters() private static class ValidCosmosDBTriggerBindigsPreferredLocations { - public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", PreferredLocations = "East US, North Europe,")] IReadOnlyList docs) + public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", PreferredLocations = "East US, North Europe,")] IReadOnlyList docs) { } - public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", PreferredLocations = "%regions%")] IReadOnlyList docs) + public static void Func2([CosmosDBTrigger("aDatabase", "aCollection", PreferredLocations = "%regions%")] IReadOnlyList docs) { } @@ -598,39 +520,5 @@ public static IEnumerable GetParameters() }; } } - - private static class ValidCosmosDBTriggerBindigsMultiMaster - { - public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", UseMultipleWriteLocations = true)] IReadOnlyList docs) - { - } - - public static IEnumerable GetParameters() - { - var type = typeof(ValidCosmosDBTriggerBindigsMultiMaster); - - return new[] - { - new[] { GetFirstParameter(type, "Func1") } - }; - } - } - - private static class ValidCosmosDBTriggerBindingsUseDefaultJsonSerialization - { - public static void Func1([CosmosDBTrigger("aDatabase", "aCollection", UseDefaultJsonSerialization = true)] IReadOnlyList docs) - { - } - - public static IEnumerable GetParameters() - { - var type = typeof(ValidCosmosDBTriggerBindingsUseDefaultJsonSerialization); - - return new[] - { - new[] { GetFirstParameter(type, "Func1") } - }; - } - } } } \ No newline at end of file diff --git a/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBTriggerHealthMonitorTests.cs b/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBTriggerHealthMonitorTests.cs deleted file mode 100644 index 549a0e70a..000000000 --- a/test/WebJobs.Extensions.CosmosDB.Tests/Trigger/CosmosDBTriggerHealthMonitorTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -// 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.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Azure.Documents.ChangeFeedProcessor; -using Microsoft.Azure.Documents.ChangeFeedProcessor.Exceptions; -using Microsoft.Azure.Documents.ChangeFeedProcessor.Monitoring; -using Microsoft.Azure.Documents.ChangeFeedProcessor.PartitionManagement; -using Microsoft.Azure.Documents.Client; -using Microsoft.Azure.WebJobs.Extensions.CosmosDB.Trigger; -using Microsoft.Azure.WebJobs.Extensions.Tests.Common; -using Microsoft.Azure.WebJobs.Host.Executors; -using Microsoft.Azure.WebJobs.Host.Scale; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; - -namespace Microsoft.Azure.WebJobs.Extensions.CosmosDB.Tests.Trigger -{ - public class CosmosDBTriggerHealthMonitorTests - { - [Fact] - public async Task LogsCritical() - { - MockedLogger mockedLogger = new MockedLogger(); - Exception exception = new Exception(); - MockedLease lease = new MockedLease(); - CosmosDBTriggerHealthMonitor cosmosDBTriggerHealthMonitor = new CosmosDBTriggerHealthMonitor(mockedLogger); - - await cosmosDBTriggerHealthMonitor.InspectAsync(new HealthMonitoringRecord(HealthSeverity.Critical, MonitoredOperation.AcquireLease, lease, exception)); - - Assert.Single(mockedLogger.Events); - - LogEvent loggedEvent = mockedLogger.Events[0]; - Assert.Equal(LogLevel.Critical, loggedEvent.LogLevel); - Assert.Equal(exception, loggedEvent.Exception); - Assert.True(loggedEvent.Message.Contains(lease.ToString()) && loggedEvent.Message.Contains(MonitoredOperation.AcquireLease.ToString())); - } - - [Fact] - public async Task LogsError() - { - MockedLogger mockedLogger = new MockedLogger(); - Exception exception = new Exception(); - MockedLease lease = new MockedLease(); - CosmosDBTriggerHealthMonitor cosmosDBTriggerHealthMonitor = new CosmosDBTriggerHealthMonitor(mockedLogger); - - await cosmosDBTriggerHealthMonitor.InspectAsync(new HealthMonitoringRecord(HealthSeverity.Error, MonitoredOperation.AcquireLease, lease, exception)); - - Assert.Single(mockedLogger.Events); - - LogEvent loggedEvent = mockedLogger.Events[0]; - Assert.Equal(LogLevel.Error, loggedEvent.LogLevel); - Assert.Equal(exception, loggedEvent.Exception); - Assert.True(loggedEvent.Message.Contains(lease.ToString()) && loggedEvent.Message.Contains(MonitoredOperation.AcquireLease.ToString())); - Assert.True(loggedEvent.Message.Contains("encountered an error")); - } - - [Fact] - public async Task LogsLeaseLost() - { - MockedLogger mockedLogger = new MockedLogger(); - LeaseLostException exception = new LeaseLostException(); - MockedLease lease = new MockedLease(); - CosmosDBTriggerHealthMonitor cosmosDBTriggerHealthMonitor = new CosmosDBTriggerHealthMonitor(mockedLogger); - - await cosmosDBTriggerHealthMonitor.InspectAsync(new HealthMonitoringRecord(HealthSeverity.Error, MonitoredOperation.AcquireLease, lease, exception)); - - Assert.Single(mockedLogger.Events); - - LogEvent loggedEvent = mockedLogger.Events[0]; - Assert.Equal(LogLevel.Warning, loggedEvent.LogLevel); - Assert.Equal(exception, loggedEvent.Exception); - Assert.True(loggedEvent.Message.Contains(lease.ToString()) && loggedEvent.Message.Contains(MonitoredOperation.AcquireLease.ToString())); - Assert.True(loggedEvent.Message.Contains("This is expected during scaling and briefly")); - } - - [Fact] - public async Task LogsTrace() - { - MockedLogger mockedLogger = new MockedLogger(); - MockedLease lease = new MockedLease(); - CosmosDBTriggerHealthMonitor cosmosDBTriggerHealthMonitor = new CosmosDBTriggerHealthMonitor(mockedLogger); - - await cosmosDBTriggerHealthMonitor.InspectAsync(new HealthMonitoringRecord(HealthSeverity.Informational, MonitoredOperation.AcquireLease, lease, null)); - - Assert.Single(mockedLogger.Events); - - LogEvent loggedEvent = mockedLogger.Events[0]; - Assert.Equal(LogLevel.Trace, loggedEvent.LogLevel); - Assert.True(loggedEvent.Message.Contains(lease.ToString()) && loggedEvent.Message.Contains(MonitoredOperation.AcquireLease.ToString())); - } - - private class MockedLogger : ILogger - { - public List Events { get; private set; } = new List(); - - public IDisposable BeginScope(TState state) - { - throw new NotImplementedException(); - } - - public bool IsEnabled(LogLevel logLevel) - { - throw new NotImplementedException(); - } - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) - { - Events.Add(new LogEvent() { LogLevel = logLevel, Exception = exception, Message = state.ToString() }); - } - } - - private class LogEvent - { - public LogLevel LogLevel { get; set; } - - public Exception Exception { get; set; } - - public string Message { get; set; } - } - - private class MockedLease : ILease - { - public string PartitionId => throw new NotImplementedException(); - - public string Owner { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - - public DateTime Timestamp { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - - public string ContinuationToken { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - - public string Id => throw new NotImplementedException(); - - public string ConcurrencyToken => throw new NotImplementedException(); - - public Dictionary Properties { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - - public override string ToString() - { - return "Mocked Lease"; - } - } - } -}