Add project files.

This commit is contained in:
2025-08-18 17:11:43 +03:30
parent 807dc263be
commit e5eb10d604
3943 changed files with 223719 additions and 0 deletions
@@ -0,0 +1,7 @@
namespace Elsa.Resilience;
[AttributeUsage(AttributeTargets.Class)]
public class ResilienceCategoryAttribute(string category) : Attribute
{
public string Category { get; } = category;
}
@@ -0,0 +1,10 @@
using Polly;
namespace Elsa.Resilience;
public interface IResilienceStrategy
{
string Id { get; set; }
string DisplayName { get; set; }
Task ConfigurePipeline<T>(ResiliencePipelineBuilder<T> pipelineBuilder, ResilienceContext context);
}
@@ -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);
}
@@ -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);
}
@@ -0,0 +1,6 @@
namespace Elsa.Resilience;
public interface IResilienceStrategySource
{
Task<IEnumerable<IResilienceStrategy>> GetStrategiesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
using Elsa.Resilience.Models;
using Elsa.Workflows;
namespace Elsa.Resilience;
public interface IResilientActivity : IActivity
{
IDictionary<string, string?> CollectRetryDetails(ActivityExecutionContext context, RetryAttempt attempt);
}
@@ -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);
}
@@ -0,0 +1,9 @@
using Elsa.Common.Models;
using Elsa.Resilience.Entities;
namespace Elsa.Resilience;
public interface IRetryAttemptReader
{
Task<Page<RetryAttemptRecord>> ReadAttemptsAsync(string activityInstanceId, PageArgs? pageArgs = null, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,8 @@
using Elsa.Resilience.Models;
namespace Elsa.Resilience;
public interface IRetryAttemptRecorder
{
Task RecordAsync(RecordRetryAttemptsContext context);
}
@@ -0,0 +1,20 @@
<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>
<ItemGroup>
<PackageReference Include="Polly" />
<PackageReference Include="Polly.Extensions" />
</ItemGroup>
</Project>
@@ -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>
@@ -0,0 +1,13 @@
using Elsa.Common.Entities;
namespace Elsa.Resilience.Entities;
public class RetryAttemptRecord : Entity
{
public string ActivityInstanceId { get; set; } = null!;
public string ActivityId { get; set; } = null!;
public string WorkflowInstanceId { get; set; } = null!;
public int AttemptNumber { get; set; }
public TimeSpan RetryDelay { get; set; }
public IDictionary<string, string> Details { get; set; } = new Dictionary<string, string>();
}
@@ -0,0 +1,31 @@
using System.Text.Json.Nodes;
using Elsa.Workflows;
namespace Elsa.Resilience.Extensions;
public static class ActivityExecutionExtensions
{
private const string RetriesAttemptedFlag = "HasRetryAttempts";
private const string ResilienceStrategy = "ResilienceStrategy";
public static void SetRetriesAttemptedFlag(this ActivityExecutionContext context)
{
var current = context;
while (current != null)
{
current.SetMetadata(RetriesAttemptedFlag, true);
current = current.ParentActivityExecutionContext;
}
}
public static bool GetRetriesAttemptedFlag(this ActivityExecutionContext context)
{
return context.GetMetadata(RetriesAttemptedFlag, () => false);
}
public static void SetResilienceStrategy(this ActivityExecutionContext context, JsonNode model)
{
context.SetProperty(ResilienceStrategy, model);
}
}
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>
@@ -0,0 +1,6 @@
using Elsa.Resilience.Entities;
using Elsa.Workflows;
namespace Elsa.Resilience.Models;
public record RecordRetryAttemptsContext(ActivityExecutionContext ActivityExecutionContext, ICollection<RetryAttemptRecord> Attempts, CancellationToken CancellationToken);
@@ -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)!;
}
}
@@ -0,0 +1,7 @@
namespace Elsa.Resilience.Models;
public enum ResilienceStrategyConfigMode
{
Identifier,
Expression,
}
@@ -0,0 +1,8 @@
using Elsa.Workflows;
namespace Elsa.Resilience.Models;
public record RetryAttempt(ActivityExecutionContext ActivityExecutionContext, int AttemptNumber, TimeSpan RetryDelay, object? Result, Exception? Exception)
{
internal static readonly object RetriesKey = new();
}
@@ -0,0 +1,6 @@
namespace Elsa.Resilience.Options;
public class ResilienceOptions
{
public ICollection<Type> StrategyTypes { get; set; } = new List<Type>();
}
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Elsa.Extensions;
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
}.WithConverters(new JsonStringEnumConverter());
_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)!;
}
@@ -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;
}
}
@@ -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
};
}
}
@@ -0,0 +1,112 @@
using System.Text.Json;
using Elsa.Expressions.Helpers;
using Elsa.Resilience.Entities;
using Elsa.Resilience.Extensions;
using Elsa.Resilience.Models;
using Elsa.Resilience.Serialization;
using Elsa.Workflows;
using Elsa.Workflows.State;
using Polly;
using Polly.Telemetry;
namespace Elsa.Resilience;
public class ResilientActivityInvoker(
IResilienceStrategyConfigEvaluator resilienceStrategyConfigEvaluator,
IRetryAttemptRecorder retryAttemptRecorder,
IIdentityGenerator identityGenerator,
ResilienceStrategySerializer resilienceStrategySerializer) : IResilientActivityInvoker
{
private const string ResilienceStrategyIdPropKey = "resilienceStrategy";
public async Task<T> InvokeAsync<T>(IResilientActivity activity, ActivityExecutionContext context, Func<Task<T>> action, CancellationToken cancellationToken = default)
{
// Get the resilience strategy.
var strategyConfig = GetStrategyConfig(activity);
var resilienceStrategy = await resilienceStrategyConfigEvaluator.EvaluateAsync(strategyConfig, context.ExpressionExecutionContext, cancellationToken);
// If no resilience strategy is configured, execute the action as-is.
if (resilienceStrategy == null)
return await action();
// Record the applied strategy as part of the activity execution context for diagnostics.
var resilienceStrategyModel = JsonSerializer.SerializeToNode(resilienceStrategy, resilienceStrategySerializer.SerializerOptions)!;
context.SetResilienceStrategy(resilienceStrategyModel);
// Create a resilience pipeline builder.
var builder = CreateResiliencePipelineBuilder<T>();
var retries = new List<RetryAttempt>();
context.TransientProperties[RetryAttempt.RetriesKey] = retries;
// Create a resilience context.
var resilienceContext = ResilienceContextPool.Shared.Get(cancellationToken);
resilienceContext.Properties.Set(new(nameof(ActivityExecutionContext)), context);
try
{
// Configure the resilience pipeline.
await resilienceStrategy.ConfigurePipeline(builder, resilienceContext);
var pipeline = builder.Build();
// Execute the action within the resilience pipeline.
var result = await pipeline.ExecuteAsync<T>(async _ => await action(), resilienceContext);
// Record the retry attempts.
await RecordRetryAttempts(activity, context, retries, cancellationToken);
return result;
}
finally
{
ResilienceContextPool.Shared.Return(resilienceContext);
}
}
private async Task RecordRetryAttempts(IResilientActivity activity, ActivityExecutionContext context, ICollection<RetryAttempt> attempts, CancellationToken cancellationToken = default)
{
if (attempts.Count > 0)
{
var records = Map(context, activity, attempts);
var recordContext = new RecordRetryAttemptsContext(context, records, cancellationToken);
await retryAttemptRecorder.RecordAsync(recordContext);
// Propagate a flag that retries have occurred. This information can then be used to show the retry attempts in the workflow designer.
context.SetRetriesAttemptedFlag();
}
}
private ResiliencePipelineBuilder<T> CreateResiliencePipelineBuilder<T>()
{
var telemetryOptions = new TelemetryOptions();
telemetryOptions.TelemetryListeners.Add(new RetryTelemetryListener());
return new ResiliencePipelineBuilder<T>().ConfigureTelemetry(telemetryOptions);
}
private ResilienceStrategyConfig? GetStrategyConfig(IResilientActivity resilientActivity)
{
return !resilientActivity.CustomProperties.TryGetValue(ResilienceStrategyIdPropKey, out var value)
? null
: value.ConvertTo<ResilienceStrategyConfig>();
}
private ICollection<RetryAttemptRecord> Map(ActivityExecutionContext activityExecutionContext, IResilientActivity resilientActivity, ICollection<RetryAttempt> attempts)
{
return attempts.Select(x => Map(activityExecutionContext, resilientActivity, x)).ToList();
}
private RetryAttemptRecord Map(ActivityExecutionContext activityExecutionContext, IResilientActivity resilientActivity, RetryAttempt attempt)
{
var details = resilientActivity.CollectRetryDetails(activityExecutionContext, attempt).Where(x => x.Value != null).ToDictionary(x => x.Key, x => x.Value!);
return new()
{
Id = identityGenerator.GenerateId(),
ActivityInstanceId = activityExecutionContext.Id,
ActivityId = activityExecutionContext.Activity.Id,
WorkflowInstanceId = activityExecutionContext.WorkflowExecutionContext.Id,
AttemptNumber = attempt.AttemptNumber,
RetryDelay = attempt.RetryDelay,
Details = details
};
}
}
@@ -0,0 +1,27 @@
using Elsa.Resilience.Models;
using Elsa.Workflows;
using Polly.Retry;
using Polly.Telemetry;
namespace Elsa.Resilience;
public class RetryTelemetryListener : TelemetryListener
{
public override void Write<TResult, TArgs>(in TelemetryEventArguments<TResult, TArgs> args)
{
if (args.Event.EventName != "OnRetry" || args.Arguments is not OnRetryArguments<TResult> retryArgs)
return;
var ctx = args.Context;
if (!ctx.Properties.TryGetValue<ActivityExecutionContext>(new(nameof(ActivityExecutionContext)), out var activityExecutionContext))
return;
var attempt = retryArgs.AttemptNumber;
var delay = retryArgs.RetryDelay;
var outcome = retryArgs.Outcome;
var record = new RetryAttempt(activityExecutionContext, attempt, delay, outcome.Result, outcome.Exception);
var records = (List<RetryAttempt>)activityExecutionContext.TransientProperties[RetryAttempt.RetriesKey];
records.Add(record);
}
}
@@ -0,0 +1,14 @@
using Elsa.Common.Models;
using Elsa.Resilience.Entities;
namespace Elsa.Resilience;
public class VoidRetryAttemptReader : IRetryAttemptReader
{
public static VoidRetryAttemptReader Instance { get; } = new();
public Task<Page<RetryAttemptRecord>> ReadAttemptsAsync(string activityInstanceId, PageArgs? pageArgs = null, CancellationToken cancellationToken = default)
{
return Task.FromResult(Page.Empty<RetryAttemptRecord>());
}
}
@@ -0,0 +1,14 @@
using Elsa.Resilience.Models;
namespace Elsa.Resilience;
public class VoidRetryAttemptRecorder : IRetryAttemptRecorder
{
public static VoidRetryAttemptRecorder Instance { get; } = new();
public Task RecordAsync(RecordRetryAttemptsContext context)
{
// Send records into the void.
return Task.CompletedTask;
}
}