From ee9b1ae649fd35733b1281fd9447490c18cf7c96 Mon Sep 17 00:00:00 2001 From: Jonathan Ruckert Date: Wed, 25 Sep 2019 21:13:42 +0930 Subject: [PATCH] Added in DocumentDb Persistence layer (#79) --- Elsa.sln | 33 ++-- .../DocumentDbStorage.cs | 80 ++++++++++ .../DocumentDbStorageOptions.cs | 68 ++++++++ .../WorkflowDefinitionVersionDocument.cs | 36 +++++ .../Documents/WorkflowInstanceDocument.cs | 45 ++++++ .../Elsa.Persistence.DocumentDb.csproj | 18 +++ .../Extensions/ServiceCollectionExtensions.cs | 32 ++++ .../WorkflowDefinitionDocumentExtensions.cs | 28 ++++ .../Helpers/ClientHelper.cs | 146 ++++++++++++++++++ .../Helpers/QueryHelper.cs | 28 ++++ .../Helpers/TimeHelper.cs | 28 ++++ .../Mapping/DocumentProfile.cs | 15 ++ .../CosmosDbWorkflowDefinitionStore.cs | 94 +++++++++++ .../Services/CosmosDbWorkflowInstanceStore.cs | 114 ++++++++++++++ 14 files changed, 752 insertions(+), 13 deletions(-) create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/DocumentDbStorage.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/DocumentDbStorageOptions.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Documents/WorkflowDefinitionVersionDocument.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Documents/WorkflowInstanceDocument.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Elsa.Persistence.DocumentDb.csproj create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Extensions/WorkflowDefinitionDocumentExtensions.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Helpers/ClientHelper.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Helpers/QueryHelper.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Helpers/TimeHelper.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Mapping/DocumentProfile.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Services/CosmosDbWorkflowDefinitionStore.cs create mode 100644 src/persistence/Elsa.Persistence.DocumentDb/Services/CosmosDbWorkflowInstanceStore.cs diff --git a/Elsa.sln b/Elsa.sln index 8ea9c84cc..24c3411d2 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -19,35 +19,37 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Abstractions", "src\co EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "core", "core", "{35F44BE9-13D0-417C-A01A-F0787BEE6DC3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.Console", "src\activities\Elsa.Activities.Console\Elsa.Activities.Console.csproj", "{B98B80CB-D3A3-437C-A61C-ED82C8C77B2F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Activities.Console", "src\activities\Elsa.Activities.Console\Elsa.Activities.Console.csproj", "{B98B80CB-D3A3-437C-A61C-ED82C8C77B2F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.Http", "src\activities\Elsa.Activities.Http\Elsa.Activities.Http.csproj", "{1D63E1B4-2386-4BEC-9090-C3F3CBF25703}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Activities.Http", "src\activities\Elsa.Activities.Http\Elsa.Activities.Http.csproj", "{1D63E1B4-2386-4BEC-9090-C3F3CBF25703}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "activities", "activities", "{B43B546E-23F3-46E8-ACB7-D04F05CDA180}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.Email", "src\activities\Elsa.Activities.Email\Elsa.Activities.Email.csproj", "{D20FCB88-9DCA-49EA-9CC2-5B94DD935D30}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Activities.Email", "src\activities\Elsa.Activities.Email\Elsa.Activities.Email.csproj", "{D20FCB88-9DCA-49EA-9CC2-5B94DD935D30}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.Timers", "src\activities\Elsa.Activities.Timers\Elsa.Activities.Timers.csproj", "{E4B71DC4-3E73-49C3-9B4E-CA13909F222D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Activities.Timers", "src\activities\Elsa.Activities.Timers\Elsa.Activities.Timers.csproj", "{E4B71DC4-3E73-49C3-9B4E-CA13909F222D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.MassTransit", "src\activities\Elsa.Activities.MassTransit\Elsa.Activities.MassTransit.csproj", "{C4939482-9447-47D1-B6AF-E9F8C6323841}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Activities.MassTransit", "src\activities\Elsa.Activities.MassTransit\Elsa.Activities.MassTransit.csproj", "{C4939482-9447-47D1-B6AF-E9F8C6323841}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "persistence", "persistence", "{76710230-AC6D-4E54-9A06-15F34C155118}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.YesSql", "src\persistence\Elsa.Persistence.YesSql\Elsa.Persistence.YesSql.csproj", "{4DB0741F-EAD9-4E37-862D-A1DA12462B7D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Persistence.YesSql", "src\persistence\Elsa.Persistence.YesSql\Elsa.Persistence.YesSql.csproj", "{4DB0741F-EAD9-4E37-862D-A1DA12462B7D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.UserTask", "src\activities\Elsa.Activities.UserTask\Elsa.Activities.UserTask.csproj", "{BC0F57E2-AB64-4026-B6E3-E03D9133DB26}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Activities.UserTask", "src\activities\Elsa.Activities.UserTask\Elsa.Activities.UserTask.csproj", "{BC0F57E2-AB64-4026-B6E3-E03D9133DB26}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.AutoMapper.Extensions", "src\core\Elsa.AutoMapper.Extensions\Elsa.AutoMapper.Extensions.csproj", "{6CEF6AFB-635D-4FB4-B9EC-0806DD95E1BC}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.AutoMapper.Extensions", "src\core\Elsa.AutoMapper.Extensions\Elsa.AutoMapper.Extensions.csproj", "{6CEF6AFB-635D-4FB4-B9EC-0806DD95E1BC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.EntityFrameworkCore", "src\persistence\Elsa.Persistence.EntityFrameworkCore\Elsa.Persistence.EntityFrameworkCore.csproj", "{349D4C87-E17F-4F51-B493-1994BF631D37}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Persistence.EntityFrameworkCore", "src\persistence\Elsa.Persistence.EntityFrameworkCore\Elsa.Persistence.EntityFrameworkCore.csproj", "{349D4C87-E17F-4F51-B493-1994BF631D37}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dashboard", "dashboard", "{8B87D79A-F0DE-4F88-81D3-8C8C69108ADB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dashboard", "src\dashboard\Elsa.Dashboard\Elsa.Dashboard.csproj", "{493FC3E3-34FE-4F8D-AF37-38A5F5541FBD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Dashboard", "src\dashboard\Elsa.Dashboard\Elsa.Dashboard.csproj", "{493FC3E3-34FE-4F8D-AF37-38A5F5541FBD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.MongoDb", "src\persistence\Elsa.Persistence.MongoDb\Elsa.Persistence.MongoDb.csproj", "{82CDD590-2409-4C7E-8AF4-07C7D718D4D8}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Persistence.MongoDb", "src\persistence\Elsa.Persistence.MongoDb\Elsa.Persistence.MongoDb.csproj", "{82CDD590-2409-4C7E-8AF4-07C7D718D4D8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.WorkflowDesigner", "src\dashboard\Elsa.WorkflowDesigner\Elsa.WorkflowDesigner.csproj", "{120E4BF9-4A42-4BB9-A0B2-9597160D6E37}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.WorkflowDesigner", "src\dashboard\Elsa.WorkflowDesigner\Elsa.WorkflowDesigner.csproj", "{120E4BF9-4A42-4BB9-A0B2-9597160D6E37}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.DocumentDb", "src\persistence\Elsa.Persistence.DocumentDb\Elsa.Persistence.DocumentDb.csproj", "{39EBECE7-538B-431C-AAB7-901AB16E6D4A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -111,6 +113,10 @@ Global {120E4BF9-4A42-4BB9-A0B2-9597160D6E37}.Debug|Any CPU.Build.0 = Debug|Any CPU {120E4BF9-4A42-4BB9-A0B2-9597160D6E37}.Release|Any CPU.ActiveCfg = Release|Any CPU {120E4BF9-4A42-4BB9-A0B2-9597160D6E37}.Release|Any CPU.Build.0 = Release|Any CPU + {39EBECE7-538B-431C-AAB7-901AB16E6D4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39EBECE7-538B-431C-AAB7-901AB16E6D4A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39EBECE7-538B-431C-AAB7-901AB16E6D4A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39EBECE7-538B-431C-AAB7-901AB16E6D4A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -119,9 +125,9 @@ Global {300EE2D5-54C5-46F2-AD03-BB43589EA074} = {35F44BE9-13D0-417C-A01A-F0787BEE6DC3} {3130C574-557C-4DA2-933E-AED7A9EBE48A} = {35F44BE9-13D0-417C-A01A-F0787BEE6DC3} {35F44BE9-13D0-417C-A01A-F0787BEE6DC3} = {DA71CDAA-8DD3-4D5F-9FBD-8E4B37A2D925} - {B43B546E-23F3-46E8-ACB7-D04F05CDA180} = {DA71CDAA-8DD3-4D5F-9FBD-8E4B37A2D925} {B98B80CB-D3A3-437C-A61C-ED82C8C77B2F} = {B43B546E-23F3-46E8-ACB7-D04F05CDA180} {1D63E1B4-2386-4BEC-9090-C3F3CBF25703} = {B43B546E-23F3-46E8-ACB7-D04F05CDA180} + {B43B546E-23F3-46E8-ACB7-D04F05CDA180} = {DA71CDAA-8DD3-4D5F-9FBD-8E4B37A2D925} {D20FCB88-9DCA-49EA-9CC2-5B94DD935D30} = {B43B546E-23F3-46E8-ACB7-D04F05CDA180} {E4B71DC4-3E73-49C3-9B4E-CA13909F222D} = {B43B546E-23F3-46E8-ACB7-D04F05CDA180} {C4939482-9447-47D1-B6AF-E9F8C6323841} = {B43B546E-23F3-46E8-ACB7-D04F05CDA180} @@ -134,6 +140,7 @@ Global {493FC3E3-34FE-4F8D-AF37-38A5F5541FBD} = {8B87D79A-F0DE-4F88-81D3-8C8C69108ADB} {82CDD590-2409-4C7E-8AF4-07C7D718D4D8} = {76710230-AC6D-4E54-9A06-15F34C155118} {120E4BF9-4A42-4BB9-A0B2-9597160D6E37} = {8B87D79A-F0DE-4F88-81D3-8C8C69108ADB} + {39EBECE7-538B-431C-AAB7-901AB16E6D4A} = {76710230-AC6D-4E54-9A06-15F34C155118} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8B0975FD-7050-48B0-88C5-48C33378E158} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/DocumentDbStorage.cs b/src/persistence/Elsa.Persistence.DocumentDb/DocumentDbStorage.cs new file mode 100644 index 000000000..67ad695ff --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/DocumentDbStorage.cs @@ -0,0 +1,80 @@ +using Microsoft.Azure.Documents; +using Microsoft.Azure.Documents.Client; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using System; +using System.Threading.Tasks; + +namespace Elsa.Persistence.DocumentDb +{ + public class DocumentDbStorage + { + internal DocumentDbStorageOptions Options { get; } + + internal DocumentClient Client { get; } + + internal Uri CollectionUri { get; private set; } + + public DocumentDbStorage(string url, string authSecret, string database, string collection, DocumentDbStorageOptions options = null) + { + Options = options ?? new DocumentDbStorageOptions(); + Options.DatabaseName = database; + Options.CollectionName = collection; + + JsonSerializerSettings settings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + ContractResolver = new CamelCasePropertyNamesContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy(false, false) + } + }; + + ConnectionPolicy connectionPolicy = ConnectionPolicy.Default; + connectionPolicy.ConnectionMode = Options.ConnectionMode; + connectionPolicy.ConnectionProtocol = Options.ConnectionProtocol; + connectionPolicy.RequestTimeout = Options.RequestTimeout; + connectionPolicy.RetryOptions = new RetryOptions + { + MaxRetryWaitTimeInSeconds = 10, + MaxRetryAttemptsOnThrottledRequests = 5 + }; + + Client = new DocumentClient(new Uri(url), authSecret, settings, connectionPolicy); + Task task = Client.OpenAsync(); + Task continueTask = task.ContinueWith(t => Initialize(), TaskContinuationOptions.OnlyOnRanToCompletion); + continueTask.Wait(); + } + + /// + /// Return the name of the database + /// + /// + public override string ToString() => $"DoucmentDb Database : {Options.DatabaseName}"; + + private void Initialize() + { + //_logger.Info($"Creating database : {Options.DatabaseName}"); + var databaseTask = Client.CreateDatabaseIfNotExistsAsync(new Database { Id = Options.DatabaseName }); + + // create document collection + Task> collectionTask = databaseTask.ContinueWith(t => + { + //_logger.Info($"Creating document collection : {t.Result.Resource.Id}"); + Uri databaseUri = UriFactory.CreateDatabaseUri(t.Result.Resource.Id); + return Client.CreateDocumentCollectionIfNotExistsAsync(databaseUri, new DocumentCollection { Id = Options.CollectionName }); + }, TaskContinuationOptions.OnlyOnRanToCompletion).Unwrap(); + + var continueTask = collectionTask.ContinueWith(t => + { + CollectionUri = UriFactory.CreateDocumentCollectionUri(Options.DatabaseName, t.Result.Resource.Id); + }, TaskContinuationOptions.OnlyOnRanToCompletion); + continueTask.Wait(); + if (continueTask.IsFaulted || continueTask.IsCanceled) + { + throw new ApplicationException("Unable to setup the storage database", databaseTask.Exception); + } + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/DocumentDbStorageOptions.cs b/src/persistence/Elsa.Persistence.DocumentDb/DocumentDbStorageOptions.cs new file mode 100644 index 000000000..70c934e45 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/DocumentDbStorageOptions.cs @@ -0,0 +1,68 @@ +using Microsoft.Azure.Documents.Client; +using System; + +namespace Elsa.Persistence.DocumentDb +{ + public class DocumentDbStorageOptions + { + /// + /// Gets or sets the name of the database. + /// + /// + /// The name of the database. + /// + internal string DatabaseName { get; set; } + + /// + /// Gets or sets the name of the collection. + /// + /// + /// The name of the collection. + /// + internal string CollectionName { get; set; } + + /// + /// Get or sets the request timeout for DocumentDB client. Default value set to 30 seconds + /// + public TimeSpan RequestTimeout { get; set; } + + /// + /// Get or set the interval timespan to process expired entries. Default value 15 minutes + /// Expired items under "locks", "jobs", "lists", "sets", "hashs", "counters/aggregated" will be checked + /// + public TimeSpan ExpirationCheckInterval { get; set; } + + /// + /// Get or sets the interval timespan to aggregated the counters. Default value 1 minute + /// + public TimeSpan CountersAggregateInterval { get; set; } + + /// + /// Gets or sets the interval timespan to poll the queue for processing any new jobs. Default value 2 minutes + /// + public TimeSpan QueuePollInterval { get; set; } + + /// + /// Gets or sets the connection mode for the DocumentDB client. Default value is Direct. + /// + public ConnectionMode ConnectionMode { get; set; } + + /// + /// Gets or sets the connection protocol for the DocumentDB client. Default value is TCP. + /// + public Protocol ConnectionProtocol { get; set; } + + /// + /// Create an instance of AzureDocumentDB Storage option with default values + /// + public DocumentDbStorageOptions() + { + RequestTimeout = TimeSpan.FromSeconds(30); + ExpirationCheckInterval = TimeSpan.FromMinutes(2); + CountersAggregateInterval = TimeSpan.FromMinutes(2); + QueuePollInterval = TimeSpan.FromSeconds(15); + ConnectionMode = ConnectionMode.Direct; + ConnectionProtocol = Protocol.Tcp; + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Documents/WorkflowDefinitionVersionDocument.cs b/src/persistence/Elsa.Persistence.DocumentDb/Documents/WorkflowDefinitionVersionDocument.cs new file mode 100644 index 000000000..dd98a2168 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Documents/WorkflowDefinitionVersionDocument.cs @@ -0,0 +1,36 @@ +using Elsa.Models; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Elsa.Persistence.DocumentDb.Documents +{ + public class WorkflowDefinitionVersionDocument + { + [JsonProperty(PropertyName = "id")] + public string Id { get; set; } + [JsonProperty(PropertyName = "type")] + public string Type { get; } = nameof(WorkflowDefinitionVersionDocument); + [JsonProperty(PropertyName = "definitionId")] + public string DefinitionId { get; set; } + [JsonProperty(PropertyName = "version")] + public int Version { get; set; } + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + [JsonProperty(PropertyName = "description")] + public string Description { get; set; } + [JsonProperty(PropertyName = "activities")] + public IList Activities { get; set; } + [JsonProperty(PropertyName = "connections")] + public IList Connections { get; set; } + [JsonProperty(PropertyName = "variables")] + public Variables Variables { get; set; } + [JsonProperty(PropertyName = "isSingleton")] + public bool IsSingleton { get; set; } + [JsonProperty(PropertyName = "isPublished")] + public bool IsPublished { get; set; } + [JsonProperty(PropertyName = "isLatest")] + public bool IsLatest { get; set; } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Documents/WorkflowInstanceDocument.cs b/src/persistence/Elsa.Persistence.DocumentDb/Documents/WorkflowInstanceDocument.cs new file mode 100644 index 000000000..fde7f87e8 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Documents/WorkflowInstanceDocument.cs @@ -0,0 +1,45 @@ +using Elsa.Models; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; + +namespace Elsa.Persistence.DocumentDb.Documents +{ + public class WorkflowInstanceDocument + { + [JsonProperty(PropertyName = "id")] + public string Id { get; set; } + [JsonProperty(PropertyName = "definitionId")] + public string DefinitionId { get; set; } + [JsonProperty(PropertyName = "type")] + public string Type { get; } = nameof(WorkflowInstanceDocument); + [JsonProperty(PropertyName = "version")] + public int Version { get; set; } + [JsonProperty(PropertyName = "status")] + public WorkflowStatus Status { get; set; } + [JsonProperty(PropertyName = "correlationId")] + public string CorrelationId { get; set; } + [JsonProperty(PropertyName = "createdAt")] + public DateTime CreatedAt { get; set; } + [JsonProperty(PropertyName = "startedAt")] + public DateTime? StartedAt { get; set; } + [JsonProperty(PropertyName = "finishedAt")] + public DateTime? FinishedAt { get; set; } + [JsonProperty(PropertyName = "faultedAt")] + public DateTime? FaultedAt { get; set; } + [JsonProperty(PropertyName = "abortedAt")] + public DateTime? AbortedAt { get; set; } + [JsonProperty(PropertyName = "activites")] + public IDictionary Activities { get; set; } = new Dictionary(); + [JsonProperty(PropertyName = "scopes")] + public Stack Scopes { get; set; } + [JsonProperty(PropertyName = "input")] + public Variables Input { get; set; } + [JsonProperty(PropertyName = "blockingActivities")] + public HashSet BlockingActivities { get; set; } + [JsonProperty(PropertyName = "executionLog")] + public ICollection ExecutionLog { get; set; } + [JsonProperty(PropertyName = "fault")] + public WorkflowFault Fault { get; set; } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Elsa.Persistence.DocumentDb.csproj b/src/persistence/Elsa.Persistence.DocumentDb/Elsa.Persistence.DocumentDb.csproj new file mode 100644 index 000000000..ae591cbd7 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Elsa.Persistence.DocumentDb.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + netcoreapp2.2 + latest + 1.0.0 + + + + + + + + + + + diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Extensions/ServiceCollectionExtensions.cs b/src/persistence/Elsa.Persistence.DocumentDb/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..6f19db4b5 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Elsa.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Elsa.Persistence.DocumentDb.Services; +using System; + +namespace Elsa.Persistence.DocumentDb.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection UseAzureCosmosDbAsWorkflowStore( + this IServiceCollection services, string url, string authSecret, string database, string collection, DocumentDbStorageOptions options = null) + { + if (string.IsNullOrEmpty(url)) throw new ArgumentNullException(nameof(url)); + if (string.IsNullOrEmpty(authSecret)) throw new ArgumentNullException(nameof(authSecret)); + + var storage = new DocumentDbStorage(url, authSecret, database, collection, options); + + return services + .AddSingleton(storage); + } + + public static IServiceCollection AddCosmosDbWorkflowInstanceStore(this IServiceCollection services) + { + return services.Replace(ServiceLifetime.Transient); + } + + public static IServiceCollection AddCosmosDbWorkflowDefinitionStore(this IServiceCollection services) + { + return services.Replace(ServiceLifetime.Transient); + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Extensions/WorkflowDefinitionDocumentExtensions.cs b/src/persistence/Elsa.Persistence.DocumentDb/Extensions/WorkflowDefinitionDocumentExtensions.cs new file mode 100644 index 000000000..c2e5a83cf --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Extensions/WorkflowDefinitionDocumentExtensions.cs @@ -0,0 +1,28 @@ +using Elsa.Models; +using Elsa.Persistence.DocumentDb.Documents; +using System.Linq; + +namespace Elsa.Persistence.DocumentDb.Extensions +{ + public static class WorkflowDefinitionDocumentExtensions + { + public static IQueryable WithVersion( + this IQueryable query, + VersionOptions version) + { + + if (version.IsDraft) + query = query.Where(x => !x.IsPublished); + if (version.IsLatest) + query = query.OrderByDescending(x => x.Version); + if (version.IsPublished) + query = query.Where(x => x.IsPublished); + if (version.IsLatestOrPublished) + query = query.Where(x => x.IsPublished || x.IsLatest); + if (version.Version > 0) + query = query.Where(x => x.Version == version.Version); + + return query.OrderByDescending(x => x.Version); + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Helpers/ClientHelper.cs b/src/persistence/Elsa.Persistence.DocumentDb/Helpers/ClientHelper.cs new file mode 100644 index 000000000..84e5f3447 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Helpers/ClientHelper.cs @@ -0,0 +1,146 @@ +using Microsoft.Azure.Documents; +using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Documents.Linq; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Elsa.Persistence.DocumentDb.Helpers +{ + internal static class ClientHelper + { + /// + /// Creates a document as an asynchronous operation in the Azure Cosmos DB service. + /// + /// + /// the URI of the document collection to create the document in. + /// the document object. + /// The request options for the request. + /// Disables the automatic id generation, will throw an exception if id is missing. + /// (Optional) representing request cancellation. + /// + internal static Task> CreateDocumentWithRetriesAsync(this DocumentClient client, Uri documentCollectionUri, object document, RequestOptions options = null, bool disableAutomaticIdGeneration = false, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.Run(async () => await client.ExecuteWithRetries(() => client.CreateDocumentAsync(documentCollectionUri, document, options, disableAutomaticIdGeneration, cancellationToken)), cancellationToken); + } + + /// + /// Reads a as a generic type T from the Azure Cosmos DB service as an asynchronous operation. + /// + /// + /// + /// A URI to the Document resource to be read. + /// The request options for the request. + /// (Optional) representing request cancellation. + /// + internal static Task> ReadDocumentWithRetriesAsync(this DocumentClient client, Uri documentUri, RequestOptions options = null, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.Run(async () => await client.ExecuteWithRetries(() => client.ReadDocumentAsync(documentUri, options, cancellationToken)), cancellationToken); + } + + /// + /// Upserts a document as an asynchronous operation in the Azure Cosmos DB service. + /// + /// + /// the URI of the document collection to upsert the document in. + /// the document object. + /// The request options for the request. + /// Disables the automatic id generation, will throw an exception if id is missing. + /// (Optional) representing request cancellation. + internal static Task> UpsertDocumentWithRetriesAsync(this DocumentClient client, Uri documentCollectionUri, object document, RequestOptions options = null, bool disableAutomaticIdGeneration = false, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.Run(async () => await client.ExecuteWithRetries(() => client.UpsertDocumentAsync(documentCollectionUri, document, options, disableAutomaticIdGeneration, cancellationToken)), cancellationToken); + } + + /// + /// Delete a document as an asynchronous operation from the Azure Cosmos DB service. + /// + /// + /// the URI of the document to delete. + /// The request options for the request. + /// (Optional) representing request cancellation. + internal static Task> DeleteDocumentWithRetriesAsync(this DocumentClient client, Uri documentUri, RequestOptions options = null, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.Run(async () => await client.ExecuteWithRetries(() => client.DeleteDocumentAsync(documentUri, options, cancellationToken)), cancellationToken); + } + + /// + /// Replaces a document as an asynchronous operation in the Azure Cosmos DB service. + /// + /// + /// the URI of the document to be updated. + /// the updated document. + /// The request options for the request. + /// (Optional) representing request cancellation. + /// + internal static Task> ReplaceDocumentWithRetriesAsync(this DocumentClient client, Uri documentUri, object document, RequestOptions options = null, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.Run(async () => await client.ExecuteWithRetries(() => client.ReplaceDocumentAsync(documentUri, document, options, cancellationToken)), cancellationToken); + } + + /// + /// Executes a stored procedure against a collection as an asynchronous operation from the Azure Cosmos DB service. + /// + /// + /// + /// the URI of the stored procedure to be executed. + /// the parameters for the stored procedure execution. + /// + internal static Task> ExecuteStoredProcedureWithRetriesAsync(this DocumentClient client, Uri storedProcedureUri, params object[] procedureParams) + { + return Task.Run(async () => await client.ExecuteWithRetries(() => client.ExecuteStoredProcedureAsync(storedProcedureUri, procedureParams))); + } + + /// + /// Execute the function with retries on throttle + /// + internal static async Task> ExecuteNextWithRetriesAsync(this IDocumentQuery query) + { + while (true) + { + TimeSpan timeSpan; + + try + { + return await query.ExecuteNextAsync(); + } + catch (DocumentClientException ex) when (ex.StatusCode != null && (int)ex.StatusCode == 429) + { + timeSpan = ex.RetryAfter; + } + catch (AggregateException ex) when (ex.InnerException is DocumentClientException de && de.StatusCode != null && (int)de.StatusCode == 429) + { + timeSpan = de.RetryAfter; + } + + await Task.Delay(timeSpan); + } + } + + /// + /// Execute the function with retries on throttle + /// + internal static async Task ExecuteWithRetries(this DocumentClient client, Func> function) + { + while (true) + { + TimeSpan timeSpan; + + try + { + return await function(); + } + catch (DocumentClientException ex) when (ex.StatusCode != null && (int)ex.StatusCode == 429) + { + timeSpan = ex.RetryAfter; + } + catch (AggregateException ex) when (ex.InnerException is DocumentClientException de && de.StatusCode != null && (int)de.StatusCode == 429) + { + timeSpan = de.RetryAfter; + } + + await Task.Delay(timeSpan); + } + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Helpers/QueryHelper.cs b/src/persistence/Elsa.Persistence.DocumentDb/Helpers/QueryHelper.cs new file mode 100644 index 000000000..37f0e2fc1 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Helpers/QueryHelper.cs @@ -0,0 +1,28 @@ +using Microsoft.Azure.Documents.Client; +using Microsoft.Azure.Documents.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Elsa.Persistence.DocumentDb.Helpers +{ + internal static class QueryHelper + { + internal static List ToQueryResult(this IQueryable source) + { + IDocumentQuery query = source.AsDocumentQuery(); + List results = new List(); + + while (query.HasMoreResults) + { + Task> task = Task.Run(async () => await query.ExecuteNextWithRetriesAsync()); + task.Wait(); + results.AddRange(task.Result); + } + + return results; + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Helpers/TimeHelper.cs b/src/persistence/Elsa.Persistence.DocumentDb/Helpers/TimeHelper.cs new file mode 100644 index 000000000..03860295b --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Helpers/TimeHelper.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Elsa.Persistence.DocumentDb.Helpers +{ + internal static class TimeHelper + { + private static readonly DateTime epochDateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); + + internal static int ToEpoch(this DateTime date) + { + if (date.Equals(DateTime.MinValue)) return int.MinValue; + TimeSpan epochTimeSpan = date - epochDateTime; + return (int)epochTimeSpan.TotalSeconds; + } + + internal static DateTime ToDateTime(this int totalSeconds) => epochDateTime.AddSeconds(totalSeconds); + + internal static string TryParseToEpoch(this string s) + { + return DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime date) + ? date.ToEpoch().ToString(CultureInfo.InvariantCulture) + : s; + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Mapping/DocumentProfile.cs b/src/persistence/Elsa.Persistence.DocumentDb/Mapping/DocumentProfile.cs new file mode 100644 index 000000000..df3c3f431 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Mapping/DocumentProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using Elsa.Models; +using Elsa.Persistence.DocumentDb.Documents; + +namespace Elsa.Persistence.DocumentDb.Mapping +{ + public class DocumentProfile : Profile + { + public DocumentProfile() + { + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Services/CosmosDbWorkflowDefinitionStore.cs b/src/persistence/Elsa.Persistence.DocumentDb/Services/CosmosDbWorkflowDefinitionStore.cs new file mode 100644 index 000000000..2644a26db --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Services/CosmosDbWorkflowDefinitionStore.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Elsa.AutoMapper.Extensions.NodaTime; +using Elsa.Extensions; +using Elsa.Models; +using Elsa.Persistence.DocumentDb.Documents; +using Elsa.Persistence.DocumentDb.Extensions; +using Elsa.Persistence.DocumentDb.Helpers; +using Elsa.Persistence.DocumentDb.Mapping; + +namespace Elsa.Persistence.DocumentDb.Services +{ + public class CosmosDbWorkflowDefinitionStore : IWorkflowDefinitionStore + { + readonly IMapper _mapper; + readonly DocumentDbStorage _storage; + + public CosmosDbWorkflowDefinitionStore(DocumentDbStorage storage) + { + _storage = storage; + var configuration = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + cfg.AddProfile(); + }); + _mapper = configuration.CreateMapper(); + } + + public async Task AddAsync(WorkflowDefinitionVersion definition, CancellationToken cancellationToken = default) + { + var document = Map(definition); + var client = _storage.Client; + await client.CreateDocumentWithRetriesAsync(_storage.CollectionUri, document); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var records = client.CreateDocumentQuery(_storage.CollectionUri).Where(c => c.DefinitionId == id).ToQueryResult(); + foreach (var record in records) + { + await client.DeleteDocumentAsync(record.Id); + } + return records.Count; + } + + public Task GetByIdAsync(string id, VersionOptions version, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri) + .Where(c => c.DefinitionId == id).WithVersion(version); + var document = query.AsEnumerable().FirstOrDefault(); + return Task.FromResult(Map(document)); + } + + public Task> ListAsync(VersionOptions version, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri) + .WithVersion(version).ToList(); + + return Task.FromResult(_mapper.Map>(query)); + } + + public async Task SaveAsync(WorkflowDefinitionVersion definition, CancellationToken cancellationToken = default) + { + var document = Map(definition); + var client = _storage.Client; + await client.UpsertDocumentWithRetriesAsync(_storage.CollectionUri, document); + return definition; + } + + public async Task UpdateAsync(WorkflowDefinitionVersion definition, CancellationToken cancellationToken = default) + { + var document = Map(definition); + var client = _storage.Client; + await client.UpsertDocumentWithRetriesAsync(_storage.CollectionUri, document); + return Map(document); + } + + private WorkflowDefinitionVersionDocument Map(WorkflowDefinitionVersion source) + { + return _mapper.Map(source); + } + + private WorkflowDefinitionVersion Map(WorkflowDefinitionVersionDocument source) + { + return _mapper.Map(source); + } + } +} diff --git a/src/persistence/Elsa.Persistence.DocumentDb/Services/CosmosDbWorkflowInstanceStore.cs b/src/persistence/Elsa.Persistence.DocumentDb/Services/CosmosDbWorkflowInstanceStore.cs new file mode 100644 index 000000000..3598117f0 --- /dev/null +++ b/src/persistence/Elsa.Persistence.DocumentDb/Services/CosmosDbWorkflowInstanceStore.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Elsa.AutoMapper.Extensions.NodaTime; +using Elsa.Extensions; +using Elsa.Models; +using Elsa.Persistence.DocumentDb.Documents; +using Elsa.Persistence.DocumentDb.Helpers; +using Elsa.Persistence.DocumentDb.Mapping; + +namespace Elsa.Persistence.DocumentDb.Services +{ + public class CosmosDbWorkflowInstanceStore : IWorkflowInstanceStore + { + readonly IMapper _mapper; + readonly DocumentDbStorage _storage; + + public CosmosDbWorkflowInstanceStore(DocumentDbStorage storage) + { + var configuration = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + cfg.AddProfile(); + }); + _mapper = configuration.CreateMapper(); + _storage = storage; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri) + .Where(c => c.Id == id); + await client.DeleteDocumentAsync(id); + } + + public Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri) + .Where(c => c.CorrelationId == correlationId); + var document = query.AsEnumerable().FirstOrDefault(); + return Task.FromResult(Map(document)); + } + + public Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri) + .Where(c => c.Id == id); + var document = query.AsEnumerable().FirstOrDefault(); + return Task.FromResult(Map(document)); + } + + public Task> ListAllAsync(CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri); + return Task.FromResult(_mapper.Map>(query)); + } + + public Task> ListByBlockingActivityAsync(string activityType, string correlationId = null, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri).Where(x => x.Status == WorkflowStatus.Executing); + if (!string.IsNullOrWhiteSpace(correlationId)) + { + query = query.Where(x => x.CorrelationId == correlationId); + } + query = query.Where(x => x.BlockingActivities.Any(y => y.ActivityType == activityType)); + var instances = Map(query.ToList()); + return Task.FromResult(instances.GetBlockingActivities(activityType)); + } + + public Task> ListByDefinitionAsync(string definitionId, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri) + .Where(c => c.DefinitionId == definitionId); + return Task.FromResult(Map(query.ToList())); + } + + public Task> ListByStatusAsync(string definitionId, WorkflowStatus status, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri) + .Where(c => c.DefinitionId == definitionId && c.Status == status); + return Task.FromResult(Map(query.ToList())); + } + + public Task> ListByStatusAsync(WorkflowStatus status, CancellationToken cancellationToken = default) + { + var client = _storage.Client; + var query = client.CreateDocumentQuery(_storage.CollectionUri) + .Where(c => c.Status == status); + return Task.FromResult(Map(query.ToList())); + } + + public async Task SaveAsync(WorkflowInstance instance, CancellationToken cancellationToken = default) + { + var document = Map(instance); + var client = _storage.Client; + await client.UpsertDocumentWithRetriesAsync(_storage.CollectionUri, document); + } + + private WorkflowInstanceDocument Map(WorkflowInstance source) => _mapper.Map(source); + private WorkflowInstance Map(WorkflowInstanceDocument source) => _mapper.Map(source); + + private IEnumerable Map(IEnumerable source) => + _mapper.Map>(source); + } +}