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.
This commit is contained in:
Sipke Schoorstra 2025-05-12 10:09:04 +02:00 committed by GitHub
parent 81cd6b0d5e
commit 9aa239719d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 698 additions and 20 deletions

View file

@ -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}

View file

@ -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;

View file

@ -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
}
]
}
}

View file

@ -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.
/// </summary>
public static void SetCommitStrategy(this JsonObject activity, string? name) => activity.SetProperty(JsonValue.Create(name), "customProperties", "commitStrategyName");
/// <summary>
/// Gets the resilience strategy for the specified activity.
/// </summary>
public static ResilienceStrategyConfig? GetResilienceStrategy(this JsonObject activity)
{
var node = activity.GetProperty("customProperties", "resilienceStrategy");
return ResilienceStrategyConfig.Deserialize(node);
}
/// <summary>
/// Sets the resilience strategy for the specified activity.
/// </summary>
public static void SetResilienceStrategy(this JsonObject activity, ResilienceStrategyConfig? config)
{
var node = config?.SerializeToNode();
activity.SetProperty(node, "customProperties", "resilienceStrategy");
}
}

View file

@ -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<IIncidentStrategiesApi>(builderOptions);
services.AddApi<ILogPersistenceStrategiesApi>(builderOptions);
services.AddApi<ICommitStrategiesApi>(builderOptions);
services.AddApi<IResilienceStrategiesApi>(builderOptions);
services.AddApi<ILoginApi>(builderOptions);
services.AddApi<IFeaturesApi>(builderOptions);
services.AddApi<IJavaScriptApi>(builderOptions);

View file

@ -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<string>("id")!;
}
public static string GetResilienceStrategyDisplayName(this JsonObject strategyJsonObject)
{
return strategyJsonObject.GetProperty<string>("displayName")!;
}
}

View file

@ -0,0 +1,20 @@
using System.Text.Json.Nodes;
using Elsa.Api.Client.Shared.Models;
using Refit;
namespace Elsa.Api.Client.Resources.ResilienceStrategies.Contracts;
/// <summary>
/// Provides operations for managing and retrieving resilience strategies within the application.
/// </summary>
public interface IResilienceStrategiesApi
{
/// <summary>
/// Retrieves a list of resilience strategies from the resilience strategies API endpoint.
/// </summary>
/// <param name="category">The category to filter the strategies by.</param>
/// <param name="cancellationToken">An optional token to cancel the operation.</param>
/// <returns>A <see cref="ListResponse{JsonObject}"/> with the list of resilience strategies.</returns>
[Get("/resilience/strategies")]
Task<ListResponse<JsonObject>> LisAsync(string category, CancellationToken cancellationToken = default);
}

View file

@ -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<ResilienceStrategyConfig?>(SerializerOptions.ResilienceStrategyConfigSerializerOptions)!;
}
}

View file

@ -0,0 +1,7 @@
namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models;
public enum ResilienceStrategyConfigMode
{
Identifier,
Expression,
}

View file

@ -20,7 +20,7 @@ public class Expression
/// </summary>
/// <param name="type">The type of the expression.</param>
/// <param name="value">The expression.</param>
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
/// <summary>
/// Gets or sets the expression type.
/// </summary>
public string Type { get; set; } = default!;
public string Type { get; set; } = null!;
/// <summary>
/// Gets or sets the value representing the expression.

View file

@ -0,0 +1,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Api.Client.Serialization;
/// <summary>
/// Provides options for serializing and deserializing objects.
/// </summary>
public static class SerializerOptions
{
public static JsonSerializerOptions CommonSerializerOptions { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
/// <summary>
/// Gets the serializer options for the log persistence configuration.
/// </summary>
public static JsonSerializerOptions LogPersistenceConfigSerializerOptions { get; } = new(CommonSerializerOptions);
public static JsonSerializerOptions ResilienceStrategyConfigSerializerOptions { get; } = new(CommonSerializerOptions);
}

View file

@ -18,7 +18,7 @@ public static class DependencyInjectionExtensions
/// <summary>
/// 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.
/// </summary>
public static IModule Use<T>(this IModule module, Action<T>? configure = default) where T: class, IFeature
public static IModule Use<T>(this IModule module, Action<T>? configure = null) where T: class, IFeature
{
module.Configure(configure);
return module;

View file

@ -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<string, object?>();
foreach (var child in children)
{
dict[child.Key] = ToObject(child);
}
return dict;
}
}

View file

@ -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() }
};
/// <summary>
/// Gets the serializer options for the log persistence configuration.
/// </summary>
public static JsonSerializerOptions LogPersistenceConfigSerializerOptions { get; } = new(CommonSerializerOptions);
public static JsonSerializerOptions ResilienceStrategyConfigSerializerOptions { get; } = new(CommonSerializerOptions);
}

View file

@ -178,7 +178,7 @@ public static class ObjectConverter
var internalSerializerOptions = InternalSerializerOptions;
if (typeof(IDictionary<string, object>).IsAssignableFrom(underlyingSourceType) && underlyingTargetType.IsClass)
if (typeof(IDictionary<string, object>).IsAssignableFrom(underlyingSourceType) && (underlyingTargetType.IsClass || underlyingTargetType.IsInterface))
{
if (typeof(ExpandoObject) == underlyingTargetType)
{

View file

@ -11,7 +11,7 @@ namespace Elsa.Http;
public class SendHttpRequest : SendHttpRequestBase
{
/// <inheritdoc />
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)
{
}

View file

@ -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.
/// </summary>
[Output(IsSerializable = false)]
public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity<HttpResponseMessage>(source, line)
[ResilienceCategory("HTTP")]
public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity<HttpResponseMessage>(source, line), IResilientActivity
{
/// <summary>
/// The URL to send the request to.
@ -94,6 +96,8 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul
/// <summary>
/// Indicates whether resiliency mechanisms should be enabled for the HTTP request.
/// </summary>
[Obsolete("Use the common Resilience Strategy setting instead.")]
[Input(Description = "Obsolete. Use the common Resilience Strategy setting instead.")]
public Input<bool> EnableResiliency { get; set; } = null!;
/// <summary>
@ -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<HttpResponseMessage> SendRequestAsync()
async Task<HttpResponseMessage> 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<IResilientActivityInvoker>();
return await resilienceService.InvokeAsync(this, activityExecutionContext, async () => await SendRequestAsyncCore(cancellationToken), cancellationToken);
}
async Task<HttpResponseMessage> SendRequestAsyncCore(CancellationToken ct = default)

View file

@ -11,10 +11,13 @@
<PackageReference Include="FluentStorage"/>
<PackageReference Include="Microsoft.Extensions.Http.Resilience"/>
<PackageReference Include="Microsoft.Extensions.Resilience"/>
<PackageReference Include="Polly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Liquid\Elsa.Liquid.csproj"/>
<ProjectReference Include="..\Elsa.Resilience.Core\Elsa.Resilience.Core.csproj" />
<ProjectReference Include="..\Elsa.Resilience\Elsa.Resilience.csproj" />
<ProjectReference Include="..\Elsa.SasTokens\Elsa.SasTokens.csproj"/>
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj"/>
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj"/>

View file

@ -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.
/// </summary>
[DependsOn(typeof(HttpJavaScriptFeature))]
[DependsOn(typeof(ResilienceFeature))]
public class HttpFeature(IModule module) : FeatureBase(module)
{
private Func<IServiceProvider, IHttpEndpointRoutesProvider> _httpEndpointRouteProvider = sp => sp.GetRequiredService<DefaultHttpEndpointRoutesProvider>();
@ -137,6 +141,8 @@ public class HttpFeature(IModule module) : FeatureBase(module)
management.AddActivitiesFrom<HttpFeature>();
});
Module.UseResilience(resilience => resilience.AddResilienceStrategyType<HttpResilienceStrategy>());
}
/// <inheritdoc />

View file

@ -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<T> ExecuteAsync<T>(Func<Task<T>> action)
{
AsyncRetryPolicy policy = Policy
.Handle<Exception>()
.WaitAndRetryAsync(
RetryCount,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(BackoffFactor, retryAttempt))
);
return await policy.ExecuteAsync(action);
}
}

View file

@ -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<EvaluatingJavaScript>,
var engine = notification.Engine;
engine.RegisterType<HttpHeaders>();
engine.RegisterType<Downloadable>();
engine.RegisterType<HttpResilienceStrategy>();
var activityExecutionContext = notification.Context;
@ -63,6 +66,7 @@ public class HttpJavaScriptHandler : INotificationHandler<EvaluatingJavaScript>,
{
yield return _typeDescriber.DescribeType(typeof(HttpHeaders));
yield return _typeDescriber.DescribeType(typeof(Downloadable));
yield return _typeDescriber.DescribeType(typeof(HttpResilienceStrategy));
}
private IEnumerable<FunctionDefinition> GetFunctionDefinitions(TypeDefinitionContext context)

View file

@ -0,0 +1,7 @@
namespace Elsa.Resilience;
[AttributeUsage(AttributeTargets.Class)]
public class ResilienceCategoryAttribute(string category) : Attribute
{
public string Category { get; } = category;
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Resilience;
public interface IResilienceStrategy
{
string Id { get; set; }
string DisplayName { get; set; }
Task<T> ExecuteAsync<T>(Func<Task<T>> action);
}

View file

@ -0,0 +1,7 @@
namespace Elsa.Resilience;
public interface IResilienceStrategyCatalog
{
Task<IEnumerable<IResilienceStrategy>> ListAsync(CancellationToken cancellationToken = default);
Task<IResilienceStrategy?> GetAsync(string id, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,9 @@
using Elsa.Expressions.Models;
using Elsa.Resilience.Models;
namespace Elsa.Resilience;
public interface IResilienceStrategyConfigEvaluator
{
Task<IResilienceStrategy?> EvaluateAsync(ResilienceStrategyConfig? config, ExpressionExecutionContext context, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,6 @@
namespace Elsa.Resilience;
public interface IResilienceStrategySource
{
Task<IEnumerable<IResilienceStrategy>> GetStrategiesAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,5 @@
using Elsa.Workflows;
namespace Elsa.Resilience;
public interface IResilientActivity : IActivity;

View file

@ -0,0 +1,21 @@
using Elsa.Workflows;
namespace Elsa.Resilience;
/// <summary>
/// Provides functionality to execute an activity's logic with a specified resilience strategy, or execute the action directly if no resilience configuration is applicable.
/// </summary>
public interface IResilientActivityInvoker
{
/// <summary>
/// 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.
/// </summary>
/// <typeparam name="T">The type of the result produced by the activity.</typeparam>
/// <param name="activity">The resilient activity being invoked.</param>
/// <param name="context">The execution context in which the activity runs.</param>
/// <param name="action">The action representing the activity's execution logic.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>The result of the invoked action.</returns>
Task<T> InvokeAsync<T>(IResilientActivity activity, ActivityExecutionContext context, Func<Task<T>> action, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides core services and types for implementing resilient services and activities.
</Description>
<PackageTags>elsa module resilience core</PackageTags>
<RootNamespace>Elsa.Resilience</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=attributes/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=contracts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -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<ResilienceStrategyConfig?>(SerializerOptions.ResilienceStrategyConfigSerializerOptions)!;
}
}

View file

@ -0,0 +1,7 @@
namespace Elsa.Resilience.Models;
public enum ResilienceStrategyConfigMode
{
Identifier,
Expression,
}

View file

@ -0,0 +1,6 @@
namespace Elsa.Resilience.Options;
public class ResilienceOptions
{
public ICollection<Type> StrategyTypes { get; set; } = new List<Type>();
}

View file

@ -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<ResilienceOptions> 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<IResilienceStrategy> strategies) => JsonSerializer.Serialize(strategies, _serializerOptions);
public IResilienceStrategy Deserialize(string json) => JsonSerializer.Deserialize<IResilienceStrategy>(json, _serializerOptions)!;
public IEnumerable<IResilienceStrategy> DeserializeMany(string json) => JsonSerializer.Deserialize<IEnumerable<IResilienceStrategy>>(json, _serializerOptions)!;
}

View file

@ -0,0 +1,31 @@
namespace Elsa.Resilience;
public class ResilienceStrategyCatalog : IResilienceStrategyCatalog
{
private readonly Lazy<Task<IEnumerable<IResilienceStrategy>>> _strategies;
private readonly IEnumerable<IResilienceStrategySource> _providers;
public ResilienceStrategyCatalog(IEnumerable<IResilienceStrategySource> providers)
{
_providers = providers;
_strategies = new(GetStrategiesInternalAsync, LazyThreadSafetyMode.ExecutionAndPublication);
}
public Task<IEnumerable<IResilienceStrategy>> ListAsync(CancellationToken cancellationToken = default)
{
return _strategies.Value;
}
public async Task<IResilienceStrategy?> GetAsync(string id, CancellationToken cancellationToken = default)
{
var strategies = await ListAsync(cancellationToken);
return strategies.FirstOrDefault(x => x.Id == id);
}
private async Task<IEnumerable<IResilienceStrategy>> GetStrategiesInternalAsync()
{
var strategies = new List<IResilienceStrategy>();
foreach (var provider in _providers) strategies.AddRange(await provider.GetStrategiesAsync());
return strategies;
}
}

View file

@ -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<IResilienceStrategy?> 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<IResilienceStrategy?> ResolveByIdentifierAsync(ResilienceStrategyConfig config, CancellationToken cancellationToken = default)
{
var strategyId = config.StrategyId;
return string.IsNullOrWhiteSpace(strategyId)
? null
: await catalog.GetAsync(strategyId, cancellationToken);
}
private async Task<IResilienceStrategy?> ResolveByExpressionAsync(ResilienceStrategyConfig config, ExpressionExecutionContext context, CancellationToken cancellationToken = default)
{
var expression = config.Expression;
if (expression == null)
return null;
var result = await expressionEvaluator.EvaluateAsync<object>(expression, context, ExpressionEvaluatorOptions.Empty);
return result switch
{
string strategyId => await catalog.GetAsync(strategyId, cancellationToken),
IResilienceStrategy strategy => strategy,
ExpandoObject expando => expando.ConvertTo<IResilienceStrategy>(new(strategySerializer.SerializerOptions)),
_ => null
};
}
}

View file

@ -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<T> InvokeAsync<T>(IResilientActivity activity, ActivityExecutionContext context, Func<Task<T>> 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<ResilienceStrategyConfig>();
}
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides features and services for implementing resilient services and activities.
</Description>
<PackageTags>elsa module resilience</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
<ProjectReference Include="..\Elsa.Resilience.Core\Elsa.Resilience.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -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<IResilienceStrategy>(strategies);
await HttpContext.Response.WriteAsJsonAsync(response, serializer.SerializerOptions, ct);
}
}

View file

@ -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<ResilienceFeature>? configure = null)
{
return module.Use(configure);
}
}

View file

@ -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<T>() where T : IResilienceStrategy
{
return AddResilienceStrategyType(typeof(T));
}
public ResilienceFeature AddResilienceStrategyType(Type strategyType)
{
Services.Configure<ResilienceOptions>(options => options.StrategyTypes.Add(strategyType));
return this;
}
public override void Configure()
{
Module.AddFastEndpointsAssembly<ResilienceFeature>();
}
public override void Apply()
{
Services.AddOptions<ResilienceOptions>();
Services
.AddSingleton<ResilienceStrategySerializer>()
.AddSingleton<IActivityDescriptorModifier, ResilientActivityDescriptorModifier>()
.AddScoped<IResilienceStrategyCatalog, ResilienceStrategyCatalog>()
.AddScoped<IResilienceStrategyConfigEvaluator, ResilienceStrategyConfigEvaluator>()
.AddScoped<IResilientActivityInvoker, ResilientActivityInvoker>()
.AddScoped<IResilienceStrategySource, ConfigurationResilienceStrategySource>();
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -0,0 +1,29 @@
using Elsa.Workflows;
using Elsa.Workflows.Models;
namespace Elsa.Resilience.Modifiers;
/// <summary>
/// Modifies the <see cref="ActivityDescriptor"/> of the <see cref="Cron"/> activity.
/// </summary>
public class ResilientActivityDescriptorModifier : IActivityDescriptorModifier
{
/// <inheritdoc />
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;
}
}

View file

@ -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<IEnumerable<IResilienceStrategy>> GetStrategiesAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(GetStrategies());
}
private IEnumerable<IResilienceStrategy> GetStrategies()
{
var json = configuration.GetSectionAsJson("Resilience:Strategies");
return string.IsNullOrWhiteSpace(json) ? [] : serializer.DeserializeMany(json);
}
}

View file

@ -13,17 +13,17 @@ public class ActivityDescriptor
/// <summary>
/// The fully qualified name of the activity type.
/// </summary>
public string TypeName { get; set; } = default!;
public string TypeName { get; set; } = null!;
/// <summary>
/// The namespace of the activity type.
/// </summary>
public string Namespace { get; set; } = default!;
public string Namespace { get; set; } = null!;
/// <summary>
/// The name of the activity type.
/// </summary>
public string Name { get; set; } = default!;
public string Name { get; set; } = null!;
/// <summary>
/// The version of the activity type.
@ -33,7 +33,7 @@ public class ActivityDescriptor
/// <summary>
/// The category of the activity type.
/// </summary>
public string Category { get; set; } = default!;
public string Category { get; set; } = null!;
/// <summary>
/// The display name of the activity type.
@ -64,7 +64,7 @@ public class ActivityDescriptor
/// Instantiates a concrete instance of an <see cref="IActivity"/>.
/// </summary>
[JsonIgnore]
public Func<ActivityConstructorContext, IActivity> Constructor { get; set; } = default!;
public Func<ActivityConstructorContext, IActivity> Constructor { get; set; } = null!;
/// <summary>
/// The kind of activity.

View file

@ -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)

View file

@ -73,7 +73,7 @@ public class WorkflowDefinitionActivityProvider(IWorkflowDefinitionStore store,
ports.Insert(0, rootPort);
return new ActivityDescriptor
return new()
{
TypeName = typeName,
Name = typeName,