From 9aa239719d1a1a4da9d5d6be16ff351b99970824 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 12 May 2025 10:09:04 +0200 Subject: [PATCH] Add IResilienceStrategy Abstraction with Category Matching and Expression-Based Configuration (#6637) * Add resilience module with core interfaces and services Introduced a new `Elsa.Resilience` module and its core components to support resilient services and activities. This includes resilience strategies, providers, and attributes, along with integration into the existing HTTP module for enhanced fault tolerance. Added solution and project references for proper dependency management. * Add resilience strategy framework with HTTP strategy support Introduced a resilience strategy architecture, including a configurable `HttpResilienceStrategy` with retry capabilities, strategy serialization, and integration with existing modules. Enhanced ResilienceFeature to support registration of strategy types and updated application configuration to enable resilience strategies. This change ensures more robust and fault-tolerant HTTP request handling. * Add JSON serialization support for resilience configuration Introduced `ConfigurationExtensions` to enable JSON serialization of configuration sections. Updated resilience strategies to utilize the new extension methods and adjusted JSON serialization logic to support polymorphism with `$type` discriminator. Minor modifications were made to support deserialization and property mutability. * Add resilience strategy support to workflows and API clients Introduced resilience strategy configuration, serialization, and execution support across workflows and API clients. Added new APIs, models, and services to enhance fault tolerance capabilities for activities and HTTP interactions. * Refactor resilience services for improved modularity. Replaced `IResilienceService` with new modular interfaces (`IResilienceStrategyCatalog`, `IResilienceStrategyConfigEvaluator`, `IResilientActivityInvoker`) and corresponding implementations. Enhanced maintainability by simplifying components and responsibilities, ensuring better separation of concerns. * Rename methods in ResilienceStrategyCatalog for clarity Updated method names in `ResilienceStrategyCatalog` and its interfaces for better readability and alignment with naming conventions. Replaced `GetAllStrategiesAsync` with `ListAsync` and `GetStrategyAsync` with `GetAsync` across the codebase. * Refactor resilience handling in HTTP activities. Replaced `ResilienceCategory` property with `ResilienceCategoryAttribute` for a cleaner implementation. Updated `IResilientActivity` to simplify its interface and adjusted related modifications accordingly. Introduced `IResilientActivityInvoker` to enhance resilience strategy execution. * Add support for additional resilience and scripting features Extended resilience strategy handling with serialization support, added `HttpResilienceStrategy` type in JavaScript handler, and refined object conversion logic for interfaces. Minor adjustments to `Expression` class properties for consistency. * Remove `UseResilience` call from Program.cs This call was redundant and no longer necessary for the application. Its removal simplifies the code and ensures only required middleware is used. * Remove commented-out JSON converter code in serializer setup Cleaned up unused and commented-out converter initialization code in `ResilienceStrategySerializer`. This improves readability and removes unnecessary clutter from the file. * Fix typo in method names from 'Resiliency' to 'Resilience' Renamed methods to maintain consistency in naming conventions across the codebase. This change ensures clarity and alignment with established terminology. * Refactor namespace for ConfigurationResilienceStrategySource Updated the namespace of ConfigurationResilienceStrategySource to "StrategySources" for better alignment with naming conventions and structure. Removed an unused namespace reference in ResilienceFeature for cleanup. * Mark EnableResiliency as obsolete in SendHttpRequestBase. The EnableResiliency property is now marked with the [Obsolete] attribute. Developers are encouraged to use the common Resilience Strategy setting instead for managing HTTP request resiliency. This change ensures better consistency and alignment with the broader resilience strategy. * Restrict ResilienceCategoryAttribute to class targets only Removed support for using ResilienceCategoryAttribute on properties. This change enforces a stricter and more focused usage of the attribute, ensuring it applies only to class-level declarations. * Add documentation for IResilientActivityInvoker interface Include summaries and parameter descriptions for the `InvokeAsync` method. This improves code clarity and helps developers understand the functionality and usage of the resilient activity invocation process. * Fix logical operator precedence in type comparison check Parentheses were added to ensure correct evaluation of conditions when checking type compatibility. This prevents potential logical errors when determining the target type in object conversions. * Add support for resilience source identification Introduce the `ResilienceSourceNameAttribute` to allow naming of resilience sources. Updated `ResilienceStrategyCatalog` to utilize the attribute for prefixing strategy IDs, improving source identification and traceability. Applied the attribute to `ConfigurationResilienceStrategySource` as an example. * Revert "Add support for resilience source identification" This reverts commit 19b4e7121d6330b5de4f692b78da4c1e4a2d1f67. * Reapply "Add support for resilience source identification" This reverts commit 8bcba9d040c4eb247077aec6d90dc02817adcbd5. * Revert "Reapply "Add support for resilience source identification"" This reverts commit ee04d35e7930956c752dda3ed150ca34a535e66c. --- Elsa.sln | 17 +++++++ src/apps/Elsa.Server.Web/Program.cs | 5 +- src/apps/Elsa.Server.Web/appsettings.json | 18 +++++++ .../Extensions/ActivityExtensions.cs | 20 ++++++++ .../DependencyInjectionExtensions.cs | 2 + .../ResilienceStrategyExtensions.cs | 16 ++++++ .../Contracts/IIncidentStrategiesApi.cs | 20 ++++++++ .../Models/ResilienceStrategyConfig.cs | 23 +++++++++ .../Models/ResilienceStrategyConfigMode.cs | 7 +++ .../Resources/Scripting/Models/Expression.cs | 4 +- .../Serialization/SerializerOptions.cs | 24 +++++++++ .../DependencyInjectionExtensions.cs | 2 +- .../Extensions/ConfigurationExtensions.cs | 45 ++++++++++++++++ .../Serialization/SerializerOptions.cs | 15 ++++++ .../Helpers/ObjectConverter.cs | 2 +- .../Elsa.Http/Activities/SendHttpRequest.cs | 2 +- .../Activities/SendHttpRequestBase.cs | 16 ++++-- src/modules/Elsa.Http/Elsa.Http.csproj | 3 ++ src/modules/Elsa.Http/Features/HttpFeature.cs | 6 +++ .../Resilience/HttpResilienceStrategy.cs | 26 ++++++++++ .../JavaScript/HttpJavaScriptHandler.cs | 4 ++ .../Attributes/ResilienceCategoryAttribute.cs | 7 +++ .../Contracts/IResilienceStrategy.cs | 8 +++ .../Contracts/IResilienceStrategyCatalog.cs | 7 +++ .../IResilienceStrategyConfigEvaluator.cs | 9 ++++ .../Contracts/IResilienceStrategySource.cs | 6 +++ .../Contracts/IResilientActivity.cs | 5 ++ .../Contracts/IResilientActivityInvoker.cs | 21 ++++++++ .../Elsa.Resilience.Core.csproj | 15 ++++++ .../Elsa.Resilience.Core.csproj.DotSettings | 4 ++ .../Elsa.Resilience.Core/FodyWeavers.xml | 3 ++ .../Models/ResilienceStrategyConfig.cs | 23 +++++++++ .../Models/ResilienceStrategyConfigMode.cs | 7 +++ .../Options/ResilienceOptions.cs | 6 +++ .../ResilienceStrategySerializer.cs | 49 ++++++++++++++++++ .../Services/ResilienceStrategyCatalog.cs | 31 +++++++++++ .../ResilienceStrategyConfigEvaluator.cs | 51 +++++++++++++++++++ .../Services/ResilientActivityInvoker.cs | 24 +++++++++ .../Elsa.Resilience/Elsa.Resilience.csproj | 15 ++++++ .../ResilienceStrategies/List/Endpoint.cs | 23 +++++++++ .../Extensions/ModuleExtensions.cs | 13 +++++ .../Features/ResilienceFeature.cs | 43 ++++++++++++++++ src/modules/Elsa.Resilience/FodyWeavers.xml | 3 ++ .../ResilientActivityDescriptorModifier.cs | 29 +++++++++++ .../ConfigurationResilienceStrategySource.cs | 19 +++++++ .../Models/ActivityDescriptor.cs | 10 ++-- .../Services/ActivityDescriber.cs | 8 ++- .../WorkflowDefinitionActivityProvider.cs | 2 +- 48 files changed, 698 insertions(+), 20 deletions(-) create mode 100644 src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs create mode 100644 src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs create mode 100644 src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs create mode 100644 src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs create mode 100644 src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs create mode 100644 src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs create mode 100644 src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs create mode 100644 src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs create mode 100644 src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs create mode 100644 src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs create mode 100644 src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs create mode 100644 src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs create mode 100644 src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs create mode 100644 src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs create mode 100644 src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj create mode 100644 src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings create mode 100644 src/modules/Elsa.Resilience.Core/FodyWeavers.xml create mode 100644 src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs create mode 100644 src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs create mode 100644 src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs create mode 100644 src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs create mode 100644 src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs create mode 100644 src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs create mode 100644 src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs create mode 100644 src/modules/Elsa.Resilience/Elsa.Resilience.csproj create mode 100644 src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs create mode 100644 src/modules/Elsa.Resilience/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.Resilience/Features/ResilienceFeature.cs create mode 100644 src/modules/Elsa.Resilience/FodyWeavers.xml create mode 100644 src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs create mode 100644 src/modules/Elsa.Resilience/StrategySources/ConfigurationResilienceStrategySource.cs diff --git a/Elsa.sln b/Elsa.sln index da59e9a5a..98441e50b 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -397,6 +397,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A7 doc\bounty\bounty-quality-standards.md = doc\bounty\bounty-quality-standards.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resilience", "resilience", "{CD7DC0D1-FFDC-417A-89BE-7F32408F583E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core", "src\modules\Elsa.Resilience.Core\Elsa.Resilience.Core.csproj", "{70593549-8B26-4D63-9857-6BA8BB3E31DB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience", "src\modules\Elsa.Resilience\Elsa.Resilience.csproj", "{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -821,6 +827,14 @@ Global {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Debug|Any CPU.Build.0 = Debug|Any CPU {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.ActiveCfg = Release|Any CPU {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.Build.0 = Release|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.Build.0 = Release|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -966,6 +980,9 @@ Global {8A050229-DB79-4E0B-9AFF-7565E87F2954} = {B08B4E00-C2AB-48F3-8389-449F42AEF179} {0A04B1FD-06C0-4271-A910-A08C263DBC44} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} {9B80A705-2E31-4012-964A-83963DCDB384} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} + {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {70593549-8B26-4D63-9857-6BA8BB3E31DB} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index c3e315e26..8c9b023fe 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -30,6 +30,7 @@ using Elsa.MongoDb.Modules.Management; using Elsa.MongoDb.Modules.Runtime; using Elsa.MongoDb.Modules.Tenants; using Elsa.OpenTelemetry.Middleware; +using Elsa.Resilience.Extensions; using Elsa.Retention.Extensions; using Elsa.Retention.Models; using Elsa.Secrets.Extensions; @@ -105,9 +106,9 @@ const bool useTenantsFromConfiguration = true; const bool useSecrets = false; const bool disableVariableWrappers = false; const bool disableVariableCopying = false; -const bool useManualOtelInstrumentation = true; +const bool useManualOtelInstrumentation = false; -ObjectConverter.StrictMode = false; +ObjectConverter.StrictMode = true; var builder = WebApplication.CreateBuilder(args); var services = builder.Services; diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index 91735a81b..306c906a5 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -271,5 +271,23 @@ "Url": "https://localhost:6001/webhooks/run-task" } ] + }, + "Resilience": { + "Strategies": [ + { + "$type": "HttpResilienceStrategy", + "Id": "1", + "DisplayName": "Retry 3 times", + "RetryCount": 3, + "BackoffFactor": 2.0 + }, + { + "$type": "HttpResilienceStrategy", + "Id": "2", + "DisplayName": "Retry 10 times", + "RetryCount": 10, + "BackoffFactor": 2.0 + } + ] } } diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs index 4990ea81a..c16488325 100644 --- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using Elsa.Api.Client.Resources.ResilienceStrategies.Models; using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; using Elsa.Api.Client.Shared.Models; @@ -193,4 +194,23 @@ public static class ActivityExtensions /// Sets the commit state behavior for the specified activity. /// public static void SetCommitStrategy(this JsonObject activity, string? name) => activity.SetProperty(JsonValue.Create(name), "customProperties", "commitStrategyName"); + + + /// + /// Gets the resilience strategy for the specified activity. + /// + public static ResilienceStrategyConfig? GetResilienceStrategy(this JsonObject activity) + { + var node = activity.GetProperty("customProperties", "resilienceStrategy"); + return ResilienceStrategyConfig.Deserialize(node); + } + + /// + /// Sets the resilience strategy for the specified activity. + /// + public static void SetResilienceStrategy(this JsonObject activity, ResilienceStrategyConfig? config) + { + var node = config?.SerializeToNode(); + activity.SetProperty(node, "customProperties", "resilienceStrategy"); + } } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs index e6d846919..3446d0637 100644 --- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs @@ -8,6 +8,7 @@ using Elsa.Api.Client.Resources.Features.Contracts; using Elsa.Api.Client.Resources.Identity.Contracts; using Elsa.Api.Client.Resources.IncidentStrategies.Contracts; using Elsa.Api.Client.Resources.LogPersistenceStrategies; +using Elsa.Api.Client.Resources.ResilienceStrategies.Contracts; using Elsa.Api.Client.Resources.Scripting.Contracts; using Elsa.Api.Client.Resources.StorageDrivers.Contracts; using Elsa.Api.Client.Resources.Tasks.Contracts; @@ -76,6 +77,7 @@ public static class DependencyInjectionExtensions services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); + services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); diff --git a/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs new file mode 100644 index 000000000..b198f054b --- /dev/null +++ b/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Nodes; + +namespace Elsa.Api.Client.Extensions; + +public static class ResilienceStrategyExtensions +{ + public static string GetResilienceStrategyId(this JsonObject strategyJsonObject) + { + return strategyJsonObject.GetProperty("id")!; + } + + public static string GetResilienceStrategyDisplayName(this JsonObject strategyJsonObject) + { + return strategyJsonObject.GetProperty("displayName")!; + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs new file mode 100644 index 000000000..9e02f85ae --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Nodes; +using Elsa.Api.Client.Shared.Models; +using Refit; + +namespace Elsa.Api.Client.Resources.ResilienceStrategies.Contracts; + +/// +/// Provides operations for managing and retrieving resilience strategies within the application. +/// +public interface IResilienceStrategiesApi +{ + /// + /// Retrieves a list of resilience strategies from the resilience strategies API endpoint. + /// + /// The category to filter the strategies by. + /// An optional token to cancel the operation. + /// A with the list of resilience strategies. + [Get("/resilience/strategies")] + Task> LisAsync(string category, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs new file mode 100644 index 000000000..136f3e2bd --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elsa.Api.Client.Resources.Scripting.Models; +using Elsa.Api.Client.Serialization; + +namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models; + +public class ResilienceStrategyConfig +{ + public ResilienceStrategyConfigMode Mode { get; set; } + public string? StrategyId { get; set; } + public Expression? Expression { get; set; } + + public JsonNode SerializeToNode() + { + return JsonSerializer.SerializeToNode(this, SerializerOptions.ResilienceStrategyConfigSerializerOptions)!; + } + + public static ResilienceStrategyConfig? Deserialize(JsonNode? node) + { + return node.Deserialize(SerializerOptions.ResilienceStrategyConfigSerializerOptions)!; + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs new file mode 100644 index 000000000..489730c14 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs @@ -0,0 +1,7 @@ +namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models; + +public enum ResilienceStrategyConfigMode +{ + Identifier, + Expression, +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs b/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs index 130134880..718c7463b 100644 --- a/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs +++ b/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs @@ -20,7 +20,7 @@ public class Expression /// /// The type of the expression. /// The expression. - public Expression(string type, string? value = default) + public Expression(string type, string? value = null) { Type = type; Value = value; @@ -29,7 +29,7 @@ public class Expression /// /// Gets or sets the expression type. /// - public string Type { get; set; } = default!; + public string Type { get; set; } = null!; /// /// Gets or sets the value representing the expression. diff --git a/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs b/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs new file mode 100644 index 000000000..41b044930 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Api.Client.Serialization; + +/// +/// Provides options for serializing and deserializing objects. +/// +public static class SerializerOptions +{ + public static JsonSerializerOptions CommonSerializerOptions { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + /// + /// Gets the serializer options for the log persistence configuration. + /// + public static JsonSerializerOptions LogPersistenceConfigSerializerOptions { get; } = new(CommonSerializerOptions); + + public static JsonSerializerOptions ResilienceStrategyConfigSerializerOptions { get; } = new(CommonSerializerOptions); +} \ No newline at end of file diff --git a/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs b/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs index 44f0fc533..afc859d91 100644 --- a/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs +++ b/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs @@ -18,7 +18,7 @@ public static class DependencyInjectionExtensions /// /// Installs and configures the specified feature. If the feature was already installed, it is not added twice, which means it is safe to call this method multiple times. /// - public static IModule Use(this IModule module, Action? configure = default) where T: class, IFeature + public static IModule Use(this IModule module, Action? configure = null) where T: class, IFeature { module.Configure(configure); return module; diff --git a/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs b/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs new file mode 100644 index 000000000..8297401e7 --- /dev/null +++ b/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class ConfigurationExtensions +{ + public static string GetSectionAsJson(this IConfiguration configuration, string sectionKey, JsonSerializerOptions? options = null) + { + var section = configuration.GetSection(sectionKey); + var element = ConvertToJsonElement(section); + return JsonSerializer.Serialize(element, options); + } + + private static JsonElement ConvertToJsonElement(IConfigurationSection section) + { + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(ToObject(section))); + return doc.RootElement.Clone(); + } + + private static object? ToObject(IConfigurationSection section) + { + var children = section.GetChildren().ToList(); + if (!children.Any()) + return section.Value; + + if (children.All(c => int.TryParse(c.Key, out _))) + { + // Treat as array + return children + .OrderBy(c => int.Parse(c.Key)) + .Select(ToObject) + .ToList(); + } + + var dict = new Dictionary(); + foreach (var child in children) + { + dict[child.Key] = ToObject(child); + } + + return dict; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Common/Serialization/SerializerOptions.cs b/src/modules/Elsa.Common/Serialization/SerializerOptions.cs index f55c1e6ca..13357ccc6 100644 --- a/src/modules/Elsa.Common/Serialization/SerializerOptions.cs +++ b/src/modules/Elsa.Common/Serialization/SerializerOptions.cs @@ -1,11 +1,26 @@ using System.Text.Json; +using System.Text.Json.Serialization; namespace Elsa.Common.Serialization; public static class SerializerOptions { + public static JsonSerializerOptions CommonSerializerOptions { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + public static JsonSerializerOptions ConfigurationJsonSerializerOptions { get; } = new() { Converters = { new ConfigurationJsonConverter() } }; + + /// + /// Gets the serializer options for the log persistence configuration. + /// + public static JsonSerializerOptions LogPersistenceConfigSerializerOptions { get; } = new(CommonSerializerOptions); + + public static JsonSerializerOptions ResilienceStrategyConfigSerializerOptions { get; } = new(CommonSerializerOptions); } \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs b/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs index 698e1412c..7cc94cc36 100644 --- a/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs +++ b/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs @@ -178,7 +178,7 @@ public static class ObjectConverter var internalSerializerOptions = InternalSerializerOptions; - if (typeof(IDictionary).IsAssignableFrom(underlyingSourceType) && underlyingTargetType.IsClass) + if (typeof(IDictionary).IsAssignableFrom(underlyingSourceType) && (underlyingTargetType.IsClass || underlyingTargetType.IsInterface)) { if (typeof(ExpandoObject) == underlyingTargetType) { diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs index 4f9fe747a..7b27571c4 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs @@ -11,7 +11,7 @@ namespace Elsa.Http; public class SendHttpRequest : SendHttpRequestBase { /// - public SendHttpRequest([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line) + public SendHttpRequest([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { } diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index 5140e8c2b..d922d1965 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using Elsa.Extensions; using Elsa.Http.ContentWriters; using Elsa.Http.UIHints; +using Elsa.Resilience; using Elsa.Workflows; using Elsa.Workflows.Attributes; using Elsa.Workflows.UIHints; @@ -16,7 +17,8 @@ namespace Elsa.Http; /// Base class for activities that send HTTP requests. /// [Output(IsSerializable = false)] -public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity(source, line) +[ResilienceCategory("HTTP")] +public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity(source, line), IResilientActivity { /// /// The URL to send the request to. @@ -94,6 +96,8 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul /// /// Indicates whether resiliency mechanisms should be enabled for the HTTP request. /// + [Obsolete("Use the common Resilience Strategy setting instead.")] + [Input(Description = "Obsolete. Use the common Resilience Strategy setting instead.")] public Input EnableResiliency { get; set; } = null!; /// @@ -145,7 +149,7 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul try { - var response = await SendRequestAsync(); + var response = await SendRequestAsync(context); var parsedContent = await ParseContentAsync(context, response); var statusCode = (int)response.StatusCode; var responseHeaders = new HttpHeaders(response.Headers); @@ -180,15 +184,17 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul return; - async Task SendRequestAsync() + async Task SendRequestAsync(ActivityExecutionContext activityExecutionContext) { + // Keep this for backward compatibility. if (resiliencyEnabled) { var pipeline = BuildResiliencyPipeline(context); return await pipeline.ExecuteAsync(async ct => await SendRequestAsyncCore(ct), cancellationToken); } - - return await SendRequestAsyncCore(); + + var resilienceService = activityExecutionContext.GetRequiredService(); + return await resilienceService.InvokeAsync(this, activityExecutionContext, async () => await SendRequestAsyncCore(cancellationToken), cancellationToken); } async Task SendRequestAsyncCore(CancellationToken ct = default) diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj index f190f061d..7901bd00d 100644 --- a/src/modules/Elsa.Http/Elsa.Http.csproj +++ b/src/modules/Elsa.Http/Elsa.Http.csproj @@ -11,10 +11,13 @@ + + + diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 085a5c4ab..80e0e5120 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -10,10 +10,13 @@ using Elsa.Http.Handlers; using Elsa.Http.Options; using Elsa.Http.Parsers; using Elsa.Http.PortResolvers; +using Elsa.Http.Resilience; using Elsa.Http.Selectors; using Elsa.Http.Services; using Elsa.Http.Tasks; using Elsa.Http.UIHints; +using Elsa.Resilience.Extensions; +using Elsa.Resilience.Features; using Elsa.Workflows; using FluentStorage; using Microsoft.AspNetCore.Http; @@ -28,6 +31,7 @@ namespace Elsa.Http.Features; /// Installs services related to HTTP services and activities. /// [DependsOn(typeof(HttpJavaScriptFeature))] +[DependsOn(typeof(ResilienceFeature))] public class HttpFeature(IModule module) : FeatureBase(module) { private Func _httpEndpointRouteProvider = sp => sp.GetRequiredService(); @@ -137,6 +141,8 @@ public class HttpFeature(IModule module) : FeatureBase(module) management.AddActivitiesFrom(); }); + + Module.UseResilience(resilience => resilience.AddResilienceStrategyType()); } /// diff --git a/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs b/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs new file mode 100644 index 000000000..c346a5773 --- /dev/null +++ b/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs @@ -0,0 +1,26 @@ +using Elsa.Resilience; +using Polly; +using Polly.Retry; + +namespace Elsa.Http.Resilience; + +[ResilienceCategory("HTTP")] +public class HttpResilienceStrategy : IResilienceStrategy +{ + public string Id { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public int RetryCount { get; set; } = 3; + public double BackoffFactor { get; set; } = 2.0; + + public async Task ExecuteAsync(Func> action) + { + AsyncRetryPolicy policy = Policy + .Handle() + .WaitAndRetryAsync( + RetryCount, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(BackoffFactor, retryAttempt)) + ); + + return await policy.ExecuteAsync(action); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs index c71bfb661..ed51cd4d2 100644 --- a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs +++ b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs @@ -1,9 +1,11 @@ using Elsa.Extensions; +using Elsa.Http.Resilience; using Elsa.JavaScript.Notifications; using Elsa.JavaScript.TypeDefinitions.Builders; using Elsa.JavaScript.TypeDefinitions.Contracts; using Elsa.JavaScript.TypeDefinitions.Models; using Elsa.Mediator.Contracts; +using Elsa.Workflows; using JetBrains.Annotations; namespace Elsa.Http.Scripting.JavaScript; @@ -30,6 +32,7 @@ public class HttpJavaScriptHandler : INotificationHandler, var engine = notification.Engine; engine.RegisterType(); engine.RegisterType(); + engine.RegisterType(); var activityExecutionContext = notification.Context; @@ -63,6 +66,7 @@ public class HttpJavaScriptHandler : INotificationHandler, { yield return _typeDescriber.DescribeType(typeof(HttpHeaders)); yield return _typeDescriber.DescribeType(typeof(Downloadable)); + yield return _typeDescriber.DescribeType(typeof(HttpResilienceStrategy)); } private IEnumerable GetFunctionDefinitions(TypeDefinitionContext context) diff --git a/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs b/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs new file mode 100644 index 000000000..4858669c0 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs @@ -0,0 +1,7 @@ +namespace Elsa.Resilience; + +[AttributeUsage(AttributeTargets.Class)] +public class ResilienceCategoryAttribute(string category) : Attribute +{ + public string Category { get; } = category; +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs new file mode 100644 index 000000000..599f146bb --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs @@ -0,0 +1,8 @@ +namespace Elsa.Resilience; + +public interface IResilienceStrategy +{ + string Id { get; set; } + string DisplayName { get; set; } + Task ExecuteAsync(Func> action); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs new file mode 100644 index 000000000..51725949c --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs @@ -0,0 +1,7 @@ +namespace Elsa.Resilience; + +public interface IResilienceStrategyCatalog +{ + Task> ListAsync(CancellationToken cancellationToken = default); + Task GetAsync(string id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs new file mode 100644 index 000000000..ef1d7a40c --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs @@ -0,0 +1,9 @@ +using Elsa.Expressions.Models; +using Elsa.Resilience.Models; + +namespace Elsa.Resilience; + +public interface IResilienceStrategyConfigEvaluator +{ + Task EvaluateAsync(ResilienceStrategyConfig? config, ExpressionExecutionContext context, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs new file mode 100644 index 000000000..35cc05288 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs @@ -0,0 +1,6 @@ +namespace Elsa.Resilience; + +public interface IResilienceStrategySource +{ + Task> GetStrategiesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs new file mode 100644 index 000000000..d8b07e376 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs @@ -0,0 +1,5 @@ +using Elsa.Workflows; + +namespace Elsa.Resilience; + +public interface IResilientActivity : IActivity; \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs new file mode 100644 index 000000000..fd92fb28a --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs @@ -0,0 +1,21 @@ +using Elsa.Workflows; + +namespace Elsa.Resilience; + +/// +/// Provides functionality to execute an activity's logic with a specified resilience strategy, or execute the action directly if no resilience configuration is applicable. +/// +public interface IResilientActivityInvoker +{ + /// + /// Invokes a resilient activity execution with the provided action using the activity's selected resilience strategy configuration. + /// If no resilience configuration is provided or otherwise does not evaluate to a strategy, the action is called as-is. + /// + /// The type of the result produced by the activity. + /// The resilient activity being invoked. + /// The execution context in which the activity runs. + /// The action representing the activity's execution logic. + /// A token to monitor for cancellation requests. + /// The result of the invoked action. + Task InvokeAsync(IResilientActivity activity, ActivityExecutionContext context, Func> action, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj new file mode 100644 index 000000000..d36195aae --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj @@ -0,0 +1,15 @@ + + + + + Provides core services and types for implementing resilient services and activities. + + elsa module resilience core + Elsa.Resilience + + + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings new file mode 100644 index 000000000..78381e8d4 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings @@ -0,0 +1,4 @@ + + True + True + True \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/FodyWeavers.xml b/src/modules/Elsa.Resilience.Core/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs new file mode 100644 index 000000000..12e97902e --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elsa.Common.Serialization; +using Elsa.Expressions.Models; + +namespace Elsa.Resilience.Models; + +public class ResilienceStrategyConfig +{ + public ResilienceStrategyConfigMode Mode { get; set; } + public string? StrategyId { get; set; } + public Expression? Expression { get; set; } + + public JsonNode SerializeToNode() + { + return JsonSerializer.SerializeToNode(this, SerializerOptions.ResilienceStrategyConfigSerializerOptions)!; + } + + public static ResilienceStrategyConfig? Deserialize(JsonNode? node) + { + return node.Deserialize(SerializerOptions.ResilienceStrategyConfigSerializerOptions)!; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs new file mode 100644 index 000000000..e82490780 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs @@ -0,0 +1,7 @@ +namespace Elsa.Resilience.Models; + +public enum ResilienceStrategyConfigMode +{ + Identifier, + Expression, +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs b/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs new file mode 100644 index 000000000..c7f8e9b0b --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs @@ -0,0 +1,6 @@ +namespace Elsa.Resilience.Options; + +public class ResilienceOptions +{ + public ICollection StrategyTypes { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs b/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs new file mode 100644 index 000000000..451a791fa --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Elsa.Common.Converters; +using Elsa.Resilience.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Resilience.Serialization; + +public class ResilienceStrategySerializer +{ + private readonly JsonSerializerOptions _serializerOptions; + + public ResilienceStrategySerializer(IOptions options) + { + var serializerOptions = new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver().WithAddedModifier(typeInfo => + { + if (typeInfo.Type != typeof(IResilienceStrategy)) + return; + + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + var polymorphismOptions = new JsonPolymorphismOptions + { + TypeDiscriminatorPropertyName = "$type" + }; + + foreach (var type in options.Value.StrategyTypes.ToList()) + polymorphismOptions.DerivedTypes.Add(new(type, type.Name)); + + typeInfo.PolymorphismOptions = polymorphismOptions; + }), + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + _serializerOptions = serializerOptions; + } + + public JsonSerializerOptions SerializerOptions => _serializerOptions; + public string Serialize(IResilienceStrategy strategy) => JsonSerializer.Serialize(strategy, _serializerOptions); + public string SerializeMany(IEnumerable strategies) => JsonSerializer.Serialize(strategies, _serializerOptions); + public IResilienceStrategy Deserialize(string json) => JsonSerializer.Deserialize(json, _serializerOptions)!; + public IEnumerable DeserializeMany(string json) => JsonSerializer.Deserialize>(json, _serializerOptions)!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs new file mode 100644 index 000000000..9ea52ccb7 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs @@ -0,0 +1,31 @@ +namespace Elsa.Resilience; + +public class ResilienceStrategyCatalog : IResilienceStrategyCatalog +{ + private readonly Lazy>> _strategies; + private readonly IEnumerable _providers; + + public ResilienceStrategyCatalog(IEnumerable providers) + { + _providers = providers; + _strategies = new(GetStrategiesInternalAsync, LazyThreadSafetyMode.ExecutionAndPublication); + } + + public Task> ListAsync(CancellationToken cancellationToken = default) + { + return _strategies.Value; + } + + public async Task GetAsync(string id, CancellationToken cancellationToken = default) + { + var strategies = await ListAsync(cancellationToken); + return strategies.FirstOrDefault(x => x.Id == id); + } + + private async Task> GetStrategiesInternalAsync() + { + var strategies = new List(); + foreach (var provider in _providers) strategies.AddRange(await provider.GetStrategiesAsync()); + return strategies; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs new file mode 100644 index 000000000..6d4e6f362 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs @@ -0,0 +1,51 @@ +using System.Dynamic; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Helpers; +using Elsa.Expressions.Models; +using Elsa.Resilience.Models; +using Elsa.Resilience.Serialization; + +namespace Elsa.Resilience; + +public class ResilienceStrategyConfigEvaluator(IResilienceStrategyCatalog catalog, IExpressionEvaluator expressionEvaluator, ResilienceStrategySerializer strategySerializer) : IResilienceStrategyConfigEvaluator +{ + public async Task EvaluateAsync(ResilienceStrategyConfig? config, ExpressionExecutionContext context, CancellationToken cancellationToken = default) + { + if (config == null) return null; + + var mode = config.Mode; + + return mode switch + { + ResilienceStrategyConfigMode.Identifier => await ResolveByIdentifierAsync(config, cancellationToken), + ResilienceStrategyConfigMode.Expression => await ResolveByExpressionAsync(config, context, cancellationToken), + _ => null + }; + } + + private async Task ResolveByIdentifierAsync(ResilienceStrategyConfig config, CancellationToken cancellationToken = default) + { + var strategyId = config.StrategyId; + return string.IsNullOrWhiteSpace(strategyId) + ? null + : await catalog.GetAsync(strategyId, cancellationToken); + } + + private async Task ResolveByExpressionAsync(ResilienceStrategyConfig config, ExpressionExecutionContext context, CancellationToken cancellationToken = default) + { + var expression = config.Expression; + + if (expression == null) + return null; + + var result = await expressionEvaluator.EvaluateAsync(expression, context, ExpressionEvaluatorOptions.Empty); + + return result switch + { + string strategyId => await catalog.GetAsync(strategyId, cancellationToken), + IResilienceStrategy strategy => strategy, + ExpandoObject expando => expando.ConvertTo(new(strategySerializer.SerializerOptions)), + _ => null + }; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs new file mode 100644 index 000000000..cc1dcaade --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs @@ -0,0 +1,24 @@ +using Elsa.Expressions.Helpers; +using Elsa.Resilience.Models; +using Elsa.Workflows; + +namespace Elsa.Resilience; + +public class ResilientActivityInvoker(IResilienceStrategyConfigEvaluator configEvaluator) : IResilientActivityInvoker +{ + private const string ResilienceStrategyIdPropKey = "resilienceStrategy"; + + public async Task InvokeAsync(IResilientActivity activity, ActivityExecutionContext context, Func> action, CancellationToken cancellationToken = default) + { + var strategyConfig = GetStrategyConfig(activity); + var strategy = await configEvaluator.EvaluateAsync(strategyConfig, context.ExpressionExecutionContext, cancellationToken); + return strategy == null ? await action() : await strategy.ExecuteAsync(action); + } + + private ResilienceStrategyConfig? GetStrategyConfig(IResilientActivity resilientActivity) + { + return !resilientActivity.CustomProperties.TryGetValue(ResilienceStrategyIdPropKey, out var value) + ? null + : value.ConvertTo(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Elsa.Resilience.csproj b/src/modules/Elsa.Resilience/Elsa.Resilience.csproj new file mode 100644 index 000000000..6a2f5cf12 --- /dev/null +++ b/src/modules/Elsa.Resilience/Elsa.Resilience.csproj @@ -0,0 +1,15 @@ + + + + + Provides features and services for implementing resilient services and activities. + + elsa module resilience + + + + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs b/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs new file mode 100644 index 000000000..5398e10e0 --- /dev/null +++ b/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs @@ -0,0 +1,23 @@ +using Elsa.Abstractions; +using Elsa.Models; +using Elsa.Resilience.Serialization; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Resilience.Endpoints.ResilienceStrategies.List; + +public class Endpoint(IResilienceStrategyCatalog catalog, ResilienceStrategySerializer serializer) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/resilience/strategies"); + ConfigurePermissions("read:*", "read:resilience-strategies"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var strategies = (await catalog.ListAsync(ct)).ToList(); + var response = new ListResponse(strategies); + + await HttpContext.Response.WriteAsJsonAsync(response, serializer.SerializerOptions, ct); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Resilience/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..e10e7c383 --- /dev/null +++ b/src/modules/Elsa.Resilience/Extensions/ModuleExtensions.cs @@ -0,0 +1,13 @@ +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.Resilience.Features; + +namespace Elsa.Resilience.Extensions; + +public static class ModuleExtensions +{ + public static IModule UseResilience(this IModule module, Action? configure = null) + { + return module.Use(configure); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs b/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs new file mode 100644 index 000000000..740d42e9d --- /dev/null +++ b/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs @@ -0,0 +1,43 @@ +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Resilience.Modifiers; +using Elsa.Resilience.Options; +using Elsa.Resilience.Serialization; +using Elsa.Resilience.StrategySources; +using Elsa.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Resilience.Features; + +public class ResilienceFeature(IModule module) : FeatureBase(module) +{ + public ResilienceFeature AddResilienceStrategyType() where T : IResilienceStrategy + { + return AddResilienceStrategyType(typeof(T)); + } + + public ResilienceFeature AddResilienceStrategyType(Type strategyType) + { + Services.Configure(options => options.StrategyTypes.Add(strategyType)); + return this; + } + + public override void Configure() + { + Module.AddFastEndpointsAssembly(); + } + + public override void Apply() + { + Services.AddOptions(); + + Services + .AddSingleton() + .AddSingleton() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/FodyWeavers.xml b/src/modules/Elsa.Resilience/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Resilience/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs b/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs new file mode 100644 index 000000000..b2a279fe5 --- /dev/null +++ b/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs @@ -0,0 +1,29 @@ +using Elsa.Workflows; +using Elsa.Workflows.Models; + +namespace Elsa.Resilience.Modifiers; + +/// +/// Modifies the of the activity. +/// +public class ResilientActivityDescriptorModifier : IActivityDescriptorModifier +{ + /// + public void Modify(ActivityDescriptor descriptor) + { + if (!descriptor.CustomProperties.TryGetValue("Type", out var typeObj)) + return; + + if(typeObj is not Type type) + return; + + // Check if this type implements IResilientActivity. + if (!typeof(IResilientActivity).IsAssignableFrom(type)) + return; + + var categoryAttr = descriptor.Attributes.FirstOrDefault(x => x is ResilienceCategoryAttribute) as ResilienceCategoryAttribute; + var category = categoryAttr?.Category ?? "Default"; + descriptor.CustomProperties["Resilient"] = true; + descriptor.CustomProperties["ResilienceCategory"] = category; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/StrategySources/ConfigurationResilienceStrategySource.cs b/src/modules/Elsa.Resilience/StrategySources/ConfigurationResilienceStrategySource.cs new file mode 100644 index 000000000..a73a9cb43 --- /dev/null +++ b/src/modules/Elsa.Resilience/StrategySources/ConfigurationResilienceStrategySource.cs @@ -0,0 +1,19 @@ +using Elsa.Extensions; +using Elsa.Resilience.Serialization; +using Microsoft.Extensions.Configuration; + +namespace Elsa.Resilience.StrategySources; + +public class ConfigurationResilienceStrategySource(IConfiguration configuration, ResilienceStrategySerializer serializer) : IResilienceStrategySource +{ + public Task> GetStrategiesAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(GetStrategies()); + } + + private IEnumerable GetStrategies() + { + var json = configuration.GetSectionAsJson("Resilience:Strategies"); + return string.IsNullOrWhiteSpace(json) ? [] : serializer.DeserializeMany(json); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs index a80fabe55..dbf28921c 100644 --- a/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs @@ -13,17 +13,17 @@ public class ActivityDescriptor /// /// The fully qualified name of the activity type. /// - public string TypeName { get; set; } = default!; + public string TypeName { get; set; } = null!; /// /// The namespace of the activity type. /// - public string Namespace { get; set; } = default!; + public string Namespace { get; set; } = null!; /// /// The name of the activity type. /// - public string Name { get; set; } = default!; + public string Name { get; set; } = null!; /// /// The version of the activity type. @@ -33,7 +33,7 @@ public class ActivityDescriptor /// /// The category of the activity type. /// - public string Category { get; set; } = default!; + public string Category { get; set; } = null!; /// /// The display name of the activity type. @@ -64,7 +64,7 @@ public class ActivityDescriptor /// Instantiates a concrete instance of an . /// [JsonIgnore] - public Func Constructor { get; set; } = default!; + public Func Constructor { get; set; } = null!; /// /// The kind of activity. diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index f874bbeac..9533f4652 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -81,6 +81,10 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve IsStart = isStart, IsTerminal = isTerminal, Attributes = attributes, + CustomProperties = + { + ["Type"] = activityType + }, Constructor = context => { var activity = activityFactory.Create(activityType, context); @@ -143,7 +147,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve if (wrappedPropertyType.IsNullableType()) wrappedPropertyType = wrappedPropertyType.GetTypeOfNullable(); - + var uiSpecification = await propertyUIHandlerResolver.GetUIPropertiesAsync(propertyInfo, null, cancellationToken); return new InputDescriptor @@ -183,7 +187,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve { return await DescribeOutputPropertiesAsync(GetOutputProperties(activityType), cancellationToken); } - + public static string GetUIHint(Type wrappedPropertyType, InputAttribute? inputAttribute = null) { if (inputAttribute?.UIHint != null) diff --git a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityProvider.cs b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityProvider.cs index e35e71634..dbf2cd3d8 100644 --- a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityProvider.cs @@ -73,7 +73,7 @@ public class WorkflowDefinitionActivityProvider(IWorkflowDefinitionStore store, ports.Insert(0, rootPort); - return new ActivityDescriptor + return new() { TypeName = typeName, Name = typeName,