Implement retry attempt capturing (#6674)
* Update default initializations and input parameters to `null` Replaced `default!` with explicit `null` for input parameters and properties throughout various classes. Adjusted constructors' default values for consistency and readability. This change ensures better clarity and alignment with nullable reference types. * Add Polly-based resilience integration for retry tracking Introduce Polly diagnostics to log retry events in the execution context. Updated resilience strategy interfaces and implementations to support Polly's context and retry event tracking. * Refactor resilience and retry handling, add flaky endpoint. Removed custom Polly-based diagnostic listeners and observers in favor of a transient status code utility class. Introduced a mock "flaky" endpoint for testing failure scenarios and updated configuration for resilience strategies. Minor namespace fixes * Add retry attempt recording to resilience feature Introduce `IRetryAttemptRecorder` and its implementations to enable recording of retry attempts during activity execution. Updated `ResilientActivityInvoker` to persist retry attempts and modified `ResilienceFeature` to support configurable retry attempt recorders. * Add retry attempt tracking and retrieval functionality Introduced mechanisms to track and fetch retry attempts, including new interfaces, reader implementations, API endpoints, and related models. These enhancements improve resilience tracking and data access for activity execution across workflows. * Add GetOutcome method to RetryAttempt model Introduce a GetOutcome method to encapsulate logic for determining the retry attempt's outcome. It prioritizes the Result, falls back to the Exception message, or defaults to "Unknown" if neither is available. This improves clarity and reusability of the outcome evaluation. * Add scoped registration for _retryAttemptReader This change ensures that _retryAttemptReader is registered in the DI container as a scoped service. * Refactor retry mechanism to support detailed retry metadata Introduced a `CollectRetryDetails` method to `IResilientActivity` for enhanced retry data collection. Updated `RetryAttemptRecord` to include a `Details` dictionary for capturing metadata, replacing previous `Result` and `Exception` fields. These changes simplify the retry recording process and improve extensibility for tracking retry details across activities. * Add support for capturing background activity properties Introduced functionality to capture and persist background activity properties during workflow execution. This includes defining a key for properties, capturing them in middleware, and storing them in the workflow execution context. These changes ensure properties are handled consistently alongside other activity data. * Add support for storing and propagating activity execution properties Introduced a `Properties` dictionary to track additional metadata in activity execution records and stats, enabling richer diagnostics and tracing. Refactored resilience logic to improve retry handling and propagate retry-related flags in workflows. Enhanced database queries to map serialized properties for execution summaries. * Add retry propagation for background activity execution Introduced a mechanism to propagate the retry-attempted flag across activity execution contexts. Added a new notification `BackgroundActivityExecutionCompleted` and updated related middleware to send this notification. Enhanced resilience features to handle and propagate retry state effectively. * Refactor default parameters and values to use 'null'. Replaced 'default' with 'null' for optional parameters and values in `AddExecutionLogEntry`, improving clarity and ensuring semantic consistency with nullable types. No functional changes were introduced. * Refactor flaky endpoint and enhance resilience support. Replaced the "Flaky" endpoint with a more robust "SimulateResponseEndpoint" under a new module. Introduced a status code lookup utility and improved resilience strategies with configurable backoff types. Updated serialization to support enum conversions and enhanced caching behavior for response simulation. * Update activity execution models with nullable properties Replaced `default!` initializations with `null!` to ensure correct handling of nullable string properties in `ActivityExecutionRecord`. Added a new `Properties` dictionary to `ActivityExecutionRecordSummary` to store additional activity execution data. This enhances model flexibility and data extensibility. * Add support for recording resilience strategy in context Introduced a new method to store resilience strategy details in the activity execution context for enhanced diagnostics. Updated `ResilientActivityInvoker` to serialize and set the resilience strategy using this method, leveraging `JsonSerializer`. * Remove redundant PropertyNamingPolicy assignment The PropertyNamingPolicy was set to the default value (CamelCase), making the assignment unnecessary. This change simplifies the code while maintaining existing functionality. * Set JSON property naming policy to camelCase Updated JSON serialization settings to use camelCase naming for property names. This improves consistency with standard JSON naming conventions and ensures compatibility with camelCase-based APIs. * Remove unused Endpoints folder reference from project file The Endpoints folder reference in the project file was unnecessary and has been removed. This cleanup helps maintain a tidy and accurate project structure. * Remove unused RetryAttemptFilter and add Polly packages Removed the obsolete RetryAttemptFilter class as it was no longer in use. Added Polly and Polly.Extensions packages to the project to support resilience and fault-handling strategies. This update aligns with keeping dependencies relevant and reducing unused artifacts. * Add resilience integration test for FlowSendHttpRequest (#6692) * Refactor and fix resilience test cases for clarity and accuracy Simplified imports, adjusted code structure, and corrected attempt indexing logic in resilience tests. These changes improve readability, maintainability, and ensure accurate validation of retry attempts in test scenarios.
This commit is contained in:
parent
3baf11ea49
commit
7b75f0c89f
|
|
@ -86,6 +86,8 @@
|
|||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.12.0-beta.1"/>
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.12.0-beta.1"/>
|
||||
<PackageVersion Include="Polly" Version="8.5.2"/>
|
||||
<PackageVersion Include="Polly.Extensions" Version="8.5.2"/>
|
||||
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0"/>
|
||||
<PackageVersion Include="PolySharp" Version="1.15.0"/>
|
||||
<PackageVersion Include="Proto.Actor" Version="1.7.0"/>
|
||||
|
|
|
|||
25
Elsa.sln
25
Elsa.sln
|
|
@ -241,9 +241,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Activities.Integration
|
|||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.JavaScript.IntegrationTests", "test\integration\Elsa.JavaScript.IntegrationTests\Elsa.JavaScript.IntegrationTests.csproj", "{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Resilience.IntegrationTests", "test\integration\Elsa.Resilience.IntegrationTests\Elsa.Resilience.IntegrationTests.csproj", "{832675FA-C597-4554-AE6B-18F189198A1F}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "apps", "apps", "{D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
src\apps\Directory.Build.props = src\apps\Directory.Build.props
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
src\apps\Directory.Build.props = src\apps\Directory.Build.props
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Server.Web", "src\apps\Elsa.Server.Web\Elsa.Server.Web.csproj", "{5ADDDFB1-E59B-4097-97B7-8C24E2D60463}"
|
||||
|
|
@ -696,10 +698,14 @@ Global
|
|||
{99B171E6-0248-4402-836D-98947CD63772}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{99B171E6-0248-4402-836D-98947CD63772}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
|
@ -929,9 +935,10 @@ Global
|
|||
{94A61AD7-2A2B-40DB-81F3-C59D596958A8} = {C6658DE0-2B2F-47F0-BB61-2CA66D435C09}
|
||||
{4B598AF7-BD7D-4544-A274-2CDDD98F4167} = {C6658DE0-2B2F-47F0-BB61-2CA66D435C09}
|
||||
{99B171E6-0248-4402-836D-98947CD63772} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
|
||||
{D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F}
|
||||
{5ADDDFB1-E59B-4097-97B7-8C24E2D60463} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}
|
||||
{4332A6BC-434A-4AF5-A075-F1BBCDD28F5D} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
|
||||
{832675FA-C597-4554-AE6B-18F189198A1F} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
|
||||
{D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F}
|
||||
{5ADDDFB1-E59B-4097-97B7-8C24E2D60463} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}
|
||||
{97C7E531-9D5F-43FD-AA19-BF24DA13B612} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{65F2AD97-3ECF-4BA6-8AAA-1E5882FDCE68} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}
|
||||
{690B0274-291F-4D9E-BA76-54EFF7D3E4BC} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}
|
||||
|
|
|
|||
|
|
@ -277,16 +277,16 @@
|
|||
{
|
||||
"$type": "HttpResilienceStrategy",
|
||||
"Id": "1",
|
||||
"DisplayName": "Retry 3 times",
|
||||
"RetryCount": 3,
|
||||
"BackoffFactor": 2.0
|
||||
"DisplayName": "Retry 3 times, exponential",
|
||||
"MaxRetryAttempts": 3,
|
||||
"BackoffType": "Exponential"
|
||||
},
|
||||
{
|
||||
"$type": "HttpResilienceStrategy",
|
||||
"Id": "2",
|
||||
"DisplayName": "Retry 10 times",
|
||||
"RetryCount": 10,
|
||||
"BackoffFactor": 2.0
|
||||
"DisplayName": "Retry 10 times, linear",
|
||||
"MaxRetryAttempts": 10,
|
||||
"BackoffType": "Linear"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,4 @@
|
|||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\modules\Elsa.Expressions\Elsa.Expressions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using Elsa.Api.Client.Resources.ResilienceStrategies.Models;
|
||||
using Elsa.Api.Client.Resources.Resilience.Models;
|
||||
using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
|
||||
using Elsa.Api.Client.Shared.Models;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +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.Resilience.Contracts;
|
||||
using Elsa.Api.Client.Resources.Scripting.Contracts;
|
||||
using Elsa.Api.Client.Resources.StorageDrivers.Contracts;
|
||||
using Elsa.Api.Client.Resources.Tasks.Contracts;
|
||||
|
|
@ -78,6 +78,7 @@ public static class DependencyInjectionExtensions
|
|||
services.AddApi<ILogPersistenceStrategiesApi>(builderOptions);
|
||||
services.AddApi<ICommitStrategiesApi>(builderOptions);
|
||||
services.AddApi<IResilienceStrategiesApi>(builderOptions);
|
||||
services.AddApi<IRetryAttemptsApi>(builderOptions);
|
||||
services.AddApi<ILoginApi>(builderOptions);
|
||||
services.AddApi<IFeaturesApi>(builderOptions);
|
||||
services.AddApi<IJavaScriptApi>(builderOptions);
|
||||
|
|
|
|||
|
|
@ -13,22 +13,22 @@ public class ActivityExecutionRecord : Entity
|
|||
/// <summary>
|
||||
/// Gets or sets the workflow instance ID.
|
||||
/// </summary>
|
||||
public string WorkflowInstanceId { get; set; } = default!;
|
||||
public string WorkflowInstanceId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the activity ID.
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = default!;
|
||||
public string ActivityId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the activity node ID.
|
||||
/// </summary>
|
||||
public string ActivityNodeId { get; set; } = default!;
|
||||
public string ActivityNodeId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The type of the activity.
|
||||
/// </summary>
|
||||
public string ActivityType { get; set; } = default!;
|
||||
public string ActivityType { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The version of the activity type.
|
||||
|
|
@ -38,7 +38,7 @@ public class ActivityExecutionRecord : Entity
|
|||
/// <summary>
|
||||
/// The name of the activity.
|
||||
/// </summary>
|
||||
public string? ActivityName { get; set; } = default!;
|
||||
public string? ActivityName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The state of the activity at the time this record is created or last updated.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ public class ActivityExecutionRecordSummary : Entity
|
|||
/// </summary>
|
||||
public bool HasBookmarks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a collection of properties for the activity execution.
|
||||
/// </summary>
|
||||
public IDictionary<string, object?>? Properties { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the status of the activity.
|
||||
/// </summary>
|
||||
|
|
@ -74,7 +79,8 @@ public class ActivityExecutionRecordSummary : Entity
|
|||
StartedAt = record.StartedAt,
|
||||
HasBookmarks = record.HasBookmarks,
|
||||
Status = record.Status,
|
||||
CompletedAt = record.CompletedAt
|
||||
CompletedAt = record.CompletedAt,
|
||||
Properties = record.Properties
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -43,5 +43,5 @@ public class ActivityExecutionStats
|
|||
/// <summary>
|
||||
/// Gets or sets the total count of faults aggregated from the activity execution and its descendants.
|
||||
/// </summary>
|
||||
public int AggregateFaultCount { get; set; }
|
||||
public IDictionary<string, object>? Properties { get; set; }
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ using System.Text.Json.Nodes;
|
|||
using Elsa.Api.Client.Shared.Models;
|
||||
using Refit;
|
||||
|
||||
namespace Elsa.Api.Client.Resources.ResilienceStrategies.Contracts;
|
||||
namespace Elsa.Api.Client.Resources.Resilience.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Provides operations for managing and retrieving resilience strategies within the application.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using Elsa.Api.Client.Resources.Resilience.Models;
|
||||
using Elsa.Api.Client.Shared.Models;
|
||||
using Refit;
|
||||
|
||||
namespace Elsa.Api.Client.Resources.Resilience.Contracts;
|
||||
|
||||
public interface IRetryAttemptsApi
|
||||
{
|
||||
[Get("/resilience/retries/{activityInstanceId}")]
|
||||
Task<PagedListResponse<RetryAttemptRecord>> ListAsync(string activityInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ using System.Text.Json.Nodes;
|
|||
using Elsa.Api.Client.Resources.Scripting.Models;
|
||||
using Elsa.Api.Client.Serialization;
|
||||
|
||||
namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models;
|
||||
namespace Elsa.Api.Client.Resources.Resilience.Models;
|
||||
|
||||
public class ResilienceStrategyConfig
|
||||
{
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models;
|
||||
namespace Elsa.Api.Client.Resources.Resilience.Models;
|
||||
|
||||
public enum ResilienceStrategyConfigMode
|
||||
{
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using Elsa.Api.Client.Shared.Models;
|
||||
|
||||
namespace Elsa.Api.Client.Resources.Resilience.Models;
|
||||
|
||||
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; }
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ public interface IWorkflowInstancesApi
|
|||
/// <param name="take">The number of records to return.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
[Get("/workflow-instances/{workflowInstanceId}/journal")]
|
||||
Task<PagedListResponse<WorkflowExecutionLogRecord>> GetJournalAsync(string workflowInstanceId, int? skip = default, int? take = default, CancellationToken cancellationToken = default);
|
||||
Task<PagedListResponse<WorkflowExecutionLogRecord>> GetJournalAsync(string workflowInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a page of journal records for the specified workflow instance.
|
||||
|
|
@ -45,7 +45,7 @@ public interface IWorkflowInstancesApi
|
|||
/// <param name="take">The number of records to return.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
[Post("/workflow-instances/{workflowInstanceId}/journal")]
|
||||
Task<PagedListResponse<WorkflowExecutionLogRecord>> GetFilteredJournalAsync(string workflowInstanceId, GetFilteredJournalRequest? filter, int? skip = default, int? take = default, CancellationToken cancellationToken = default);
|
||||
Task<PagedListResponse<WorkflowExecutionLogRecord>> GetFilteredJournalAsync(string workflowInstanceId, GetFilteredJournalRequest? filter, int? skip = null, int? take = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the execution state of the specified workflow instance.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public record ExceptionState(string Type, string Message, string? StackTrace, Ex
|
|||
/// Initializes a new instance of the <see cref="ExceptionState"/> class.
|
||||
/// </summary>
|
||||
[JsonConstructor]
|
||||
public ExceptionState() : this(default!, default!, default, default)
|
||||
public ExceptionState() : this(null!, null!, null, null)
|
||||
{
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,5 @@ public static class Page
|
|||
/// <typeparam name="T">The type of the items.</typeparam>
|
||||
/// <returns>A new instance of the <see cref="Page{T}"/> class.</returns>
|
||||
public static Page<T> Of<T>(ICollection<T> items, long totalCount) => new(items, totalCount);
|
||||
public static Page<T> Empty<T>() => new([], 0);
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.Json;
|
||||
using Elsa.Common;
|
||||
using Elsa.Common.Codecs;
|
||||
using Elsa.Common.Entities;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Management.Options;
|
||||
|
|
@ -12,6 +14,7 @@ using Elsa.Workflows.Runtime.Filters;
|
|||
using Elsa.Workflows.Runtime.OrderDefinitions;
|
||||
using Elsa.Workflows.State;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Open.Linq.AsyncExtensions;
|
||||
|
||||
|
|
@ -60,13 +63,15 @@ public class EFCoreActivityExecutionStore(
|
|||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<ActivityExecutionRecordSummary>> FindManySummariesAsync<TOrderBy>(ActivityExecutionRecordFilter filter, ActivityExecutionRecordOrder<TOrderBy> order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await store.QueryAsync(query => Filter(query, filter), ActivityExecutionRecordSummary.FromRecordExpression(), cancellationToken).ToList().AsEnumerable();
|
||||
var shadowRecords = await store.QueryAsync(query => Filter(query, filter), FromRecordExpression(), cancellationToken).ToList();
|
||||
return Map(shadowRecords);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<ActivityExecutionRecordSummary>> FindManySummariesAsync(ActivityExecutionRecordFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await store.QueryAsync(query => Filter(query, filter), ActivityExecutionRecordSummary.FromRecordExpression(), cancellationToken).ToList().AsEnumerable();
|
||||
var shadowRecords = await store.QueryAsync(query => Filter(query, filter), FromRecordExpression(), cancellationToken).ToList();
|
||||
return Map(shadowRecords);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -142,4 +147,62 @@ public class EFCoreActivityExecutionStore(
|
|||
}
|
||||
|
||||
private static IQueryable<ActivityExecutionRecord> Filter(IQueryable<ActivityExecutionRecord> queryable, ActivityExecutionRecordFilter filter) => filter.Apply(queryable);
|
||||
|
||||
private static Expression<Func<ActivityExecutionRecord, ShadowActivityExecutionRecordSummary>> FromRecordExpression()
|
||||
{
|
||||
return record => new()
|
||||
{
|
||||
Id = record.Id,
|
||||
WorkflowInstanceId = record.WorkflowInstanceId,
|
||||
ActivityId = record.ActivityId,
|
||||
ActivityNodeId = record.ActivityNodeId,
|
||||
ActivityType = record.ActivityType,
|
||||
ActivityTypeVersion = record.ActivityTypeVersion,
|
||||
ActivityName = record.ActivityName,
|
||||
StartedAt = record.StartedAt,
|
||||
HasBookmarks = record.HasBookmarks,
|
||||
Status = record.Status,
|
||||
AggregateFaultCount = record.AggregateFaultCount,
|
||||
SerializedProperties = EF.Property<string>(record, "SerializedProperties"),
|
||||
CompletedAt = record.CompletedAt
|
||||
};
|
||||
}
|
||||
|
||||
private IEnumerable<ActivityExecutionRecordSummary> Map(IEnumerable<ShadowActivityExecutionRecordSummary> source) => source.Select(Map);
|
||||
|
||||
private ActivityExecutionRecordSummary Map(ShadowActivityExecutionRecordSummary source)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = source.Id,
|
||||
WorkflowInstanceId = source.WorkflowInstanceId,
|
||||
ActivityId = source.ActivityId,
|
||||
ActivityNodeId = source.ActivityNodeId,
|
||||
ActivityType = source.ActivityType,
|
||||
ActivityTypeVersion = source.ActivityTypeVersion,
|
||||
ActivityName = source.ActivityName,
|
||||
StartedAt = source.StartedAt,
|
||||
HasBookmarks = source.HasBookmarks,
|
||||
Status = source.Status,
|
||||
AggregateFaultCount = source.AggregateFaultCount,
|
||||
CompletedAt = source.CompletedAt,
|
||||
Properties = source.SerializedProperties is null ? null : payloadSerializer.Deserialize<IDictionary<string, object>>(source.SerializedProperties)
|
||||
};
|
||||
}
|
||||
|
||||
private class ShadowActivityExecutionRecordSummary : Entity
|
||||
{
|
||||
public string WorkflowInstanceId { get; set; } = null!;
|
||||
public string ActivityId { get; set; } = null!;
|
||||
public string ActivityNodeId { get; set; } = null!;
|
||||
public string ActivityType { get; set; } = null!;
|
||||
public int ActivityTypeVersion { get; set; }
|
||||
public string? ActivityName { get; set; }
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
public bool HasBookmarks { get; set; }
|
||||
public ActivityStatus Status { get; set; }
|
||||
public string? SerializedProperties { get; set; }
|
||||
public int AggregateFaultCount { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ using Elsa.Extensions;
|
|||
using Elsa.Http.ContentWriters;
|
||||
using Elsa.Http.UIHints;
|
||||
using Elsa.Resilience;
|
||||
using Elsa.Resilience.Models;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Attributes;
|
||||
using Elsa.Workflows.UIHints;
|
||||
|
|
@ -124,6 +125,21 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul
|
|||
await TrySendAsync(context);
|
||||
}
|
||||
|
||||
public IDictionary<string, string?> CollectRetryDetails(ActivityExecutionContext context, RetryAttempt attempt)
|
||||
{
|
||||
if (attempt.Result is not HttpResponseMessage response)
|
||||
return new Dictionary<string, string?>();
|
||||
|
||||
return new Dictionary<string, string?>
|
||||
{
|
||||
["StatusCode"] = response.StatusCode.ToString(),
|
||||
["ReasonPhrase"] = response.ReasonPhrase,
|
||||
["Content-Type"] = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream",
|
||||
["Date"] = response.Headers.Date.ToString(),
|
||||
["Retry-After"] = response.Headers.RetryAfter?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the response.
|
||||
/// </summary>
|
||||
|
|
|
|||
21
src/modules/Elsa.Http/Extensions/HttpStatusCodeExtensions.cs
Normal file
21
src/modules/Elsa.Http/Extensions/HttpStatusCodeExtensions.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using System.Net;
|
||||
|
||||
namespace Elsa.Http.Extensions;
|
||||
|
||||
public static class HttpStatusCodeExtensions
|
||||
{
|
||||
public static bool IsTransientStatusCode(this HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.TooManyRequests => true,
|
||||
HttpStatusCode.InternalServerError => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
HttpStatusCode.Conflict => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -10,11 +10,9 @@ using System.Net;
|
|||
using System.Net.Mime;
|
||||
using System.Text.Json;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Helpers;
|
||||
using Elsa.Workflows.Runtime.Entities;
|
||||
using FastEndpoints;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Management;
|
||||
using Elsa.Workflows.Management.Entities;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Elsa.Http.Extensions;
|
||||
using Elsa.Resilience;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
|
@ -9,18 +10,30 @@ 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 int MaxRetryAttempts { get; set; } = 3;
|
||||
public bool UseJitter { get; set; }
|
||||
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
public DelayBackoffType BackoffType { get; set; } = DelayBackoffType.Exponential;
|
||||
|
||||
public async Task<T> ExecuteAsync<T>(Func<Task<T>> action)
|
||||
public Task ConfigurePipeline<T>(ResiliencePipelineBuilder<T> pipelineBuilder, ResilienceContext context)
|
||||
{
|
||||
AsyncRetryPolicy policy = Policy
|
||||
.Handle<Exception>()
|
||||
.WaitAndRetryAsync(
|
||||
RetryCount,
|
||||
retryAttempt => TimeSpan.FromSeconds(Math.Pow(BackoffFactor, retryAttempt))
|
||||
);
|
||||
|
||||
return await policy.ExecuteAsync(action);
|
||||
if (typeof(T) != typeof(HttpResponseMessage))
|
||||
throw new NotSupportedException($"{nameof(HttpResilienceStrategy)} only supports HttpResponseMessage.");
|
||||
|
||||
var options = new RetryStrategyOptions<T>
|
||||
{
|
||||
ShouldHandle = new PredicateBuilder<T>()
|
||||
.Handle<TimeoutException>()
|
||||
.Handle<HttpRequestException>()
|
||||
.HandleResult(response => ((HttpResponseMessage)(object)response!).StatusCode.IsTransientStatusCode()),
|
||||
MaxRetryAttempts = MaxRetryAttempts,
|
||||
Delay = Delay,
|
||||
UseJitter = UseJitter,
|
||||
BackoffType = BackoffType,
|
||||
Name = DisplayName
|
||||
};
|
||||
|
||||
pipelineBuilder.AddRetry(options);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ using Elsa.Extensions;
|
|||
using Elsa.Http.Bookmarks;
|
||||
using Elsa.Http.Contexts;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Helpers;
|
||||
using Elsa.Workflows.Models;
|
||||
using Elsa.Workflows.Runtime;
|
||||
using Elsa.Workflows.Runtime.Entities;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// Taken & adapted from https://github.com/markvincze/Stubbery/blob/main/src/Stubbery/RequestMatching/RouteMatcher.cs
|
||||
|
||||
using Elsa.Extensions;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Routing.Template;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
namespace Elsa.Resilience;
|
||||
using Polly;
|
||||
|
||||
namespace Elsa.Resilience;
|
||||
|
||||
public interface IResilienceStrategy
|
||||
{
|
||||
string Id { get; set; }
|
||||
string DisplayName { get; set; }
|
||||
Task<T> ExecuteAsync<T>(Func<Task<T>> action);
|
||||
Task ConfigurePipeline<T>(ResiliencePipelineBuilder<T> pipelineBuilder, ResilienceContext context);
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
using Elsa.Resilience.Models;
|
||||
using Elsa.Workflows;
|
||||
|
||||
namespace Elsa.Resilience;
|
||||
|
||||
public interface IResilientActivity : IActivity;
|
||||
public interface IResilientActivity : IActivity
|
||||
{
|
||||
IDictionary<string, string?> CollectRetryDetails(ActivityExecutionContext context, RetryAttempt attempt);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -12,4 +12,9 @@
|
|||
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Polly" />
|
||||
<PackageReference Include="Polly.Extensions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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.SetProperty(RetriesAttemptedFlag, true);
|
||||
current = current.ParentActivityExecutionContext;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetRetriesAttemptedFlag(this ActivityExecutionContext context)
|
||||
{
|
||||
return context.GetProperty(RetriesAttemptedFlag, () => false);
|
||||
}
|
||||
|
||||
public static void SetResilienceStrategy(this ActivityExecutionContext context, JsonNode model)
|
||||
{
|
||||
context.SetProperty(ResilienceStrategy, model);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
8
src/modules/Elsa.Resilience.Core/Models/RetryAttempt.cs
Normal file
8
src/modules/Elsa.Resilience.Core/Models/RetryAttempt.cs
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Elsa.Common.Converters;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Resilience.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ public class ResilienceStrategySerializer
|
|||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString
|
||||
};
|
||||
}.WithConverters(new JsonStringEnumConverter());
|
||||
|
||||
_serializerOptions = serializerOptions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,86 @@
|
|||
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 configEvaluator) : IResilientActivityInvoker
|
||||
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 strategy = await configEvaluator.EvaluateAsync(strategyConfig, context.ExpressionExecutionContext, cancellationToken);
|
||||
return strategy == null ? await action() : await strategy.ExecuteAsync(action);
|
||||
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)
|
||||
|
|
@ -21,4 +89,24 @@ public class ResilientActivityInvoker(IResilienceStrategyConfigEvaluator configE
|
|||
? 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,11 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Resilience.Core\Elsa.Resilience.Core.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Polly" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -10,7 +10,7 @@ public class Endpoint(IResilienceStrategyCatalog catalog, ResilienceStrategySeri
|
|||
public override void Configure()
|
||||
{
|
||||
Get("/resilience/strategies");
|
||||
ConfigurePermissions("read:*", "read:resilience-strategies");
|
||||
ConfigurePermissions("read:*", "read:resilience", "read:resilience:strategies");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Common.Models;
|
||||
using Elsa.Models;
|
||||
using Elsa.Resilience.Entities;
|
||||
|
||||
namespace Elsa.Resilience.Endpoints.Retries.List;
|
||||
|
||||
public class Endpoint(IRetryAttemptReader reader) : ElsaEndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/resilience/retries/{activityInstanceId}");
|
||||
ConfigurePermissions("read:*", "read:resilience", "read:resilience:retries");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var skip = Query<int?>("skip", false);
|
||||
var take = Query<int?>("take", false);
|
||||
var pageArgs = skip == null && take == null ? null : PageArgs.FromRange(skip, take);
|
||||
var activityInstanceId = Route<string>("activityInstanceId");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(activityInstanceId))
|
||||
{
|
||||
AddError("ActivityInstanceId is required.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var page = await reader.ReadAttemptsAsync(activityInstanceId, pageArgs, ct);
|
||||
var response = new PagedListResponse<RetryAttemptRecord>(page);
|
||||
await SendOkAsync(response, ct);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
using System.Text.Json;
|
||||
using FastEndpoints;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using static Elsa.Resilience.Endpoints.SimulateResponse.StatusCodeMessageLookup;
|
||||
|
||||
namespace Elsa.Resilience.Endpoints.SimulateResponse;
|
||||
|
||||
public class SimulateResponseEndpoint(IMemoryCache memoryCache) : EndpointWithoutRequest<SimulatedResponse>
|
||||
{
|
||||
private static readonly TimeSpan SlidingExpirationTimeSpan = TimeSpan.FromMinutes(15);
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/simulate-response");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var sessionId = HttpContext.Request.Query["sessionId"].FirstOrDefault() ?? "default";
|
||||
var codes = GetCodes();
|
||||
var cacheKey = $"status-simulation-session-{sessionId}";
|
||||
var nextIndex = memoryCache.GetOrCreate(cacheKey, entry =>
|
||||
{
|
||||
entry.SlidingExpiration = SlidingExpirationTimeSpan;
|
||||
return 0;
|
||||
});
|
||||
|
||||
var currentCode = nextIndex < codes.Length ? codes[nextIndex] : codes[^1];
|
||||
var message = StatusMessages.TryGetValue(currentCode, out var reason)
|
||||
? reason
|
||||
: $"Status Code {currentCode}";
|
||||
|
||||
if (nextIndex + 1 >= codes.Length)
|
||||
{
|
||||
memoryCache.Remove(cacheKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
memoryCache.Set(cacheKey, nextIndex + 1, new MemoryCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = SlidingExpirationTimeSpan
|
||||
});
|
||||
}
|
||||
|
||||
await SendAsync(new(message), currentCode, ct);
|
||||
}
|
||||
|
||||
private int[] GetCodes()
|
||||
{
|
||||
var codesParam = HttpContext.Request.Query["codes"].FirstOrDefault();
|
||||
int[] defaultCodes = [429, 503, 200];
|
||||
return string.IsNullOrWhiteSpace(codesParam) ? defaultCodes : JsonSerializer.Deserialize<int[]>(codesParam)!;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
namespace Elsa.Resilience.Endpoints.SimulateResponse;
|
||||
|
||||
public record SimulatedResponse(string Message);
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
namespace Elsa.Resilience.Endpoints.SimulateResponse;
|
||||
|
||||
public static class StatusCodeMessageLookup
|
||||
{
|
||||
// Mapping of common HTTP status codes to their standard reason phrases/messages
|
||||
public static readonly Dictionary<int, string> StatusMessages = new()
|
||||
{
|
||||
[100] = "Continue",
|
||||
[101] = "Switching Protocols",
|
||||
[102] = "Processing",
|
||||
[200] = "OK",
|
||||
[201] = "Created",
|
||||
[202] = "Accepted",
|
||||
[203] = "Non-Authoritative Information",
|
||||
[204] = "No Content",
|
||||
[205] = "Reset Content",
|
||||
[206] = "Partial Content",
|
||||
[207] = "Multi-Status",
|
||||
[208] = "Already Reported",
|
||||
[226] = "IM Used",
|
||||
[300] = "Multiple Choices",
|
||||
[301] = "Moved Permanently",
|
||||
[302] = "Found",
|
||||
[303] = "See Other",
|
||||
[304] = "Not Modified",
|
||||
[305] = "Use Proxy",
|
||||
[307] = "Temporary Redirect",
|
||||
[308] = "Permanent Redirect",
|
||||
[400] = "Bad Request",
|
||||
[401] = "Unauthorized",
|
||||
[402] = "Payment Required",
|
||||
[403] = "Forbidden",
|
||||
[404] = "Not Found",
|
||||
[405] = "Method Not Allowed",
|
||||
[406] = "Not Acceptable",
|
||||
[407] = "Proxy Authentication Required",
|
||||
[408] = "Request Timeout",
|
||||
[409] = "Conflict",
|
||||
[410] = "Gone",
|
||||
[411] = "Length Required",
|
||||
[412] = "Precondition Failed",
|
||||
[413] = "Payload Too Large",
|
||||
[414] = "URI Too Long",
|
||||
[415] = "Unsupported Media Type",
|
||||
[416] = "Range Not Satisfiable",
|
||||
[417] = "Expectation Failed",
|
||||
[418] = "I'm a teapot",
|
||||
[421] = "Misdirected Request",
|
||||
[422] = "Unprocessable Entity",
|
||||
[423] = "Locked",
|
||||
[424] = "Failed Dependency",
|
||||
[425] = "Too Early",
|
||||
[426] = "Upgrade Required",
|
||||
[428] = "Precondition Required",
|
||||
[429] = "Too Many Requests",
|
||||
[431] = "Request Header Fields Too Large",
|
||||
[451] = "Unavailable For Legal Reasons",
|
||||
[500] = "Internal Server Error",
|
||||
[501] = "Not Implemented",
|
||||
[502] = "Bad Gateway",
|
||||
[503] = "Service Unavailable",
|
||||
[504] = "Gateway Timeout",
|
||||
[505] = "HTTP Version Not Supported",
|
||||
[506] = "Variant Also Negotiates",
|
||||
[507] = "Insufficient Storage",
|
||||
[508] = "Loop Detected",
|
||||
[510] = "Not Extended",
|
||||
[511] = "Network Authentication Required",
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using Elsa.Features.Abstractions;
|
|||
using Elsa.Features.Services;
|
||||
using Elsa.Resilience.Modifiers;
|
||||
using Elsa.Resilience.Options;
|
||||
using Elsa.Resilience.Recorders;
|
||||
using Elsa.Resilience.Serialization;
|
||||
using Elsa.Resilience.StrategySources;
|
||||
using Elsa.Workflows;
|
||||
|
|
@ -12,6 +13,9 @@ namespace Elsa.Resilience.Features;
|
|||
|
||||
public class ResilienceFeature(IModule module) : FeatureBase(module)
|
||||
{
|
||||
private Func<IServiceProvider, IRetryAttemptRecorder> _retryAttemptRecorder = sp => sp.GetRequiredService<ActivityExecutionContextRetryAttemptRecorder>();
|
||||
private Func<IServiceProvider, IRetryAttemptReader> _retryAttemptReader = sp => sp.GetRequiredService<ActivityExecutionContextRetryAttemptReader>();
|
||||
|
||||
public ResilienceFeature AddResilienceStrategyType<T>() where T : IResilienceStrategy
|
||||
{
|
||||
return AddResilienceStrategyType(typeof(T));
|
||||
|
|
@ -23,6 +27,33 @@ public class ResilienceFeature(IModule module) : FeatureBase(module)
|
|||
return this;
|
||||
}
|
||||
|
||||
public ResilienceFeature WithActivityExecutionContextRetryAttemptRecorder()
|
||||
{
|
||||
return WithRetryAttemptRecorder<ActivityExecutionContextRetryAttemptRecorder>()
|
||||
.WithRetryAttemptReader<ActivityExecutionContextRetryAttemptReader>();
|
||||
}
|
||||
|
||||
public ResilienceFeature WithVoidRetryAttemptRecorder()
|
||||
{
|
||||
return WithRetryAttemptRecorder<VoidRetryAttemptRecorder>()
|
||||
.WithRetryAttemptReader<VoidRetryAttemptReader>();
|
||||
}
|
||||
|
||||
public ResilienceFeature WithRetryAttemptRecorder<T>() => WithRetryAttemptRecorder(sp => (IRetryAttemptRecorder)ActivatorUtilities.CreateInstance<T>(sp)!);
|
||||
public ResilienceFeature WithRetryAttemptReader<T>() => WithRetryAttemptReader(sp => (IRetryAttemptReader)ActivatorUtilities.CreateInstance<T>(sp)!);
|
||||
|
||||
public ResilienceFeature WithRetryAttemptRecorder(Func<IServiceProvider, IRetryAttemptRecorder> recorder)
|
||||
{
|
||||
_retryAttemptRecorder = recorder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ResilienceFeature WithRetryAttemptReader(Func<IServiceProvider, IRetryAttemptReader> reader)
|
||||
{
|
||||
_retryAttemptReader = reader;
|
||||
return this;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Module.AddFastEndpointsAssembly<ResilienceFeature>();
|
||||
|
|
@ -38,6 +69,13 @@ public class ResilienceFeature(IModule module) : FeatureBase(module)
|
|||
.AddScoped<IResilienceStrategyCatalog, ResilienceStrategyCatalog>()
|
||||
.AddScoped<IResilienceStrategyConfigEvaluator, ResilienceStrategyConfigEvaluator>()
|
||||
.AddScoped<IResilientActivityInvoker, ResilientActivityInvoker>()
|
||||
.AddScoped<IResilienceStrategySource, ConfigurationResilienceStrategySource>();
|
||||
.AddScoped<IResilienceStrategySource, ConfigurationResilienceStrategySource>()
|
||||
.AddSingleton(VoidRetryAttemptRecorder.Instance)
|
||||
.AddSingleton(VoidRetryAttemptReader.Instance)
|
||||
.AddScoped<ActivityExecutionContextRetryAttemptRecorder>()
|
||||
.AddScoped<ActivityExecutionContextRetryAttemptReader>()
|
||||
.AddScoped(_retryAttemptRecorder)
|
||||
.AddScoped(_retryAttemptReader)
|
||||
.AddHandlersFrom<ResilienceFeature>();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
using Elsa.Mediator.Contracts;
|
||||
using Elsa.Resilience.Extensions;
|
||||
using Elsa.Workflows.Runtime.Notifications;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace Elsa.Resilience.Handlers;
|
||||
|
||||
[UsedImplicitly]
|
||||
public class PropagateRetryAttemptedFlag : INotificationHandler<BackgroundActivityExecutionCompleted>
|
||||
{
|
||||
public Task HandleAsync(BackgroundActivityExecutionCompleted notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var hasRetries = notification.ActivityExecutionContext.GetRetriesAttemptedFlag();
|
||||
|
||||
if (hasRetries)
|
||||
notification.ActivityExecutionContext.SetRetriesAttemptedFlag(); // Propagates the flag to all ancestors.
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using Elsa.Common.Models;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Resilience.Entities;
|
||||
using Elsa.Workflows.Runtime;
|
||||
|
||||
namespace Elsa.Resilience.Recorders;
|
||||
|
||||
public class ActivityExecutionContextRetryAttemptReader(IActivityExecutionStore activityExecutionStore) : IRetryAttemptReader
|
||||
{
|
||||
public async Task<Page<RetryAttemptRecord>> ReadAttemptsAsync(string activityInstanceId, PageArgs? pageArgs = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var record = await activityExecutionStore.FindAsync(new()
|
||||
{
|
||||
Id = activityInstanceId
|
||||
}, cancellationToken);
|
||||
|
||||
if (record?.Properties == null || !record.Properties.TryGetValue("RetryAttempts", out var value) || value is not ICollection<RetryAttemptRecord> retryAttempts)
|
||||
return Page.Empty<RetryAttemptRecord>();
|
||||
|
||||
return retryAttempts.Paginate(pageArgs);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using Elsa.Resilience.Models;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace Elsa.Resilience.Recorders;
|
||||
|
||||
[UsedImplicitly]
|
||||
public class ActivityExecutionContextRetryAttemptRecorder : IRetryAttemptRecorder
|
||||
{
|
||||
public Task RecordAsync(RecordRetryAttemptsContext context)
|
||||
{
|
||||
context.ActivityExecutionContext.Properties["RetryAttempts"] = context.Attempts;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ internal class Report : ElsaEndpoint<Request, Response>
|
|||
{
|
||||
var stats = (await _activityExecutionStatsService.GetStatsAsync(request.WorkflowInstanceId, request.ActivityNodeIds, cancellationToken)).ToList();
|
||||
|
||||
return new Response
|
||||
return new()
|
||||
{
|
||||
Stats = stats
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,17 +12,17 @@ public partial class WorkflowExecutionContext
|
|||
/// <param name="message">The message of the event.</param>
|
||||
/// <param name="payload">Any contextual data related to this event.</param>
|
||||
/// <returns>Returns the created <see cref="WorkflowExecutionLogEntry"/>.</returns>
|
||||
public WorkflowExecutionLogEntry AddExecutionLogEntry(string eventName, string? message = default, object? payload = default)
|
||||
public WorkflowExecutionLogEntry AddExecutionLogEntry(string eventName, string? message = null, object? payload = null)
|
||||
{
|
||||
var logEntry = new WorkflowExecutionLogEntry(
|
||||
Id,
|
||||
default,
|
||||
null,
|
||||
Workflow.Id,
|
||||
Workflow.Type,
|
||||
Workflow.Identity.Version,
|
||||
Workflow.Name,
|
||||
Workflow.Identity.Id,
|
||||
default,
|
||||
null,
|
||||
SystemClock.UtcNow,
|
||||
ExecutionLogSequence++,
|
||||
eventName,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ public class ActivityExecutionRecordSummary : Entity
|
|||
/// Gets or sets the status of the activity.
|
||||
/// </summary>
|
||||
public ActivityStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a dictionary of key-value pairs representing additional properties for the activity execution record summary.
|
||||
/// </summary>
|
||||
public IDictionary<string, object>? Properties { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the aggregated count of faults encountered during the execution of the activity instance and its descendants.
|
||||
|
|
@ -81,6 +86,7 @@ public class ActivityExecutionRecordSummary : Entity
|
|||
HasBookmarks = record.HasBookmarks,
|
||||
Status = record.Status,
|
||||
AggregateFaultCount = record.AggregateFaultCount,
|
||||
Properties = record.Properties?.Count > 0 ? record.Properties : null,
|
||||
CompletedAt = record.CompletedAt,
|
||||
};
|
||||
}
|
||||
|
|
@ -103,6 +109,7 @@ public class ActivityExecutionRecordSummary : Entity
|
|||
HasBookmarks = record.HasBookmarks,
|
||||
Status = record.Status,
|
||||
AggregateFaultCount = record.AggregateFaultCount,
|
||||
Properties = record.Properties,
|
||||
CompletedAt = record.CompletedAt
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
using System.Text.Json;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Mediator.Contracts;
|
||||
using Elsa.Workflows.CommitStates;
|
||||
using Elsa.Workflows.Middleware.Activities;
|
||||
using Elsa.Workflows.Models;
|
||||
using Elsa.Workflows.Options;
|
||||
using Elsa.Workflows.Pipelines.ActivityExecution;
|
||||
using Elsa.Workflows.Runtime.Notifications;
|
||||
using Elsa.Workflows.Runtime.Stimuli;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
|
@ -20,7 +22,8 @@ public class BackgroundActivityInvokerMiddleware(
|
|||
ILogger<BackgroundActivityInvokerMiddleware> logger,
|
||||
IIdentityGenerator identityGenerator,
|
||||
IBackgroundActivityScheduler backgroundActivityScheduler,
|
||||
ICommitStrategyRegistry commitStrategyRegistry)
|
||||
ICommitStrategyRegistry commitStrategyRegistry,
|
||||
IMediator mediator)
|
||||
: DefaultActivityInvokerMiddleware(next, commitStrategyRegistry, logger)
|
||||
{
|
||||
internal static string GetBackgroundActivityOutputKey(string activityNodeId) => $"__BackgroundActivityOutput:{activityNodeId}";
|
||||
|
|
@ -29,6 +32,7 @@ public class BackgroundActivityInvokerMiddleware(
|
|||
internal static string GetBackgroundActivityJournalDataKey(string activityNodeId) => $"__BackgroundActivityJournalData:{activityNodeId}";
|
||||
internal static string GetBackgroundActivityScheduledActivitiesKey(string activityNodeId) => $"__BackgroundActivityScheduledActivities:{activityNodeId}";
|
||||
internal static string GetBackgroundActivityBookmarksKey(string activityNodeId) => $"__BackgroundActivityBookmarks:{activityNodeId}";
|
||||
internal static string GetBackgroundActivityPropertiesKey(string activityNodeId) => $"__BackgroundActivityProperties:{activityNodeId}";
|
||||
internal const string BackgroundActivityBookmarkName = "BackgroundActivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -49,9 +53,11 @@ public class BackgroundActivityInvokerMiddleware(
|
|||
CaptureOutputIfAny(context);
|
||||
CaptureJournalData(context);
|
||||
CaptureBookmarkData(context);
|
||||
CapturePropertiesIfAny(context);
|
||||
await CompleteBackgroundActivityOutcomesAsync(context);
|
||||
await CompleteBackgroundActivityAsync(context);
|
||||
await CompleteBackgroundActivityScheduledActivitiesAsync(context);
|
||||
await mediator.SendAsync(new BackgroundActivityExecutionCompleted(context), context.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +161,21 @@ public class BackgroundActivityInvokerMiddleware(
|
|||
|
||||
context.WorkflowExecutionContext.Properties.Remove(bookmarksKey);
|
||||
}
|
||||
|
||||
private void CapturePropertiesIfAny(ActivityExecutionContext context)
|
||||
{
|
||||
var activity = context.Activity;
|
||||
var propertiesKey = GetBackgroundActivityPropertiesKey(activity.NodeId);
|
||||
var capturedProperties = context.WorkflowExecutionContext.GetProperty<IDictionary<string, object>>(propertiesKey);
|
||||
|
||||
context.WorkflowExecutionContext.Properties.Remove(propertiesKey);
|
||||
|
||||
if (capturedProperties == null)
|
||||
return;
|
||||
|
||||
foreach (var property in capturedProperties)
|
||||
context.Properties[property.Key] = property.Value;
|
||||
}
|
||||
|
||||
private async Task CompleteBackgroundActivityOutcomesAsync(ActivityExecutionContext context)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -44,4 +44,9 @@ public class ActivityExecutionStats
|
|||
/// Gets or sets the total count of faults aggregated from the activity execution and its descendants.
|
||||
/// </summary>
|
||||
public int AggregateFaultCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional properties related to the activity execution.
|
||||
/// </summary>
|
||||
public IDictionary<string, object>? Properties { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
using Elsa.Mediator.Contracts;
|
||||
|
||||
namespace Elsa.Workflows.Runtime.Notifications;
|
||||
|
||||
public record BackgroundActivityExecutionCompleted(ActivityExecutionContext ActivityExecutionContext) : INotification;
|
||||
|
|
@ -16,7 +16,7 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService
|
|||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<ActivityExecutionStats>> GetStatsAsync(string workflowInstanceId, IEnumerable<string> activityNodeIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
|
@ -28,18 +28,24 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService
|
|||
var order = new ActivityExecutionRecordOrder<DateTimeOffset>(x => x.StartedAt, OrderDirection.Ascending);
|
||||
var records = (await _store.FindManySummariesAsync(filter, order, cancellationToken)).ToList();
|
||||
var groupedRecords = records.GroupBy(x => x.ActivityNodeId).ToList();
|
||||
var stats = groupedRecords.Select(grouping => new ActivityExecutionStats
|
||||
var stats = groupedRecords.Select(grouping =>
|
||||
{
|
||||
ActivityNodeId = grouping.Key,
|
||||
ActivityId = grouping.First().ActivityId,
|
||||
StartedCount = grouping.Count(),
|
||||
CompletedCount = grouping.Count(x => x.CompletedAt != null),
|
||||
UncompletedCount = grouping.Count(x => x.CompletedAt == null),
|
||||
IsBlocked = grouping.Any(x => x.HasBookmarks),
|
||||
IsFaulted = grouping.Any(x => x.Status == ActivityStatus.Faulted),
|
||||
AggregateFaultCount = grouping.Last().AggregateFaultCount
|
||||
var first = grouping.First();
|
||||
var last = grouping.Last();
|
||||
return new ActivityExecutionStats
|
||||
{
|
||||
ActivityNodeId = grouping.Key,
|
||||
ActivityId = first.ActivityId,
|
||||
StartedCount = grouping.Count(),
|
||||
CompletedCount = grouping.Count(x => x.CompletedAt != null),
|
||||
UncompletedCount = grouping.Count(x => x.CompletedAt == null),
|
||||
IsBlocked = grouping.Any(x => x.HasBookmarks),
|
||||
IsFaulted = grouping.Any(x => x.Status == ActivityStatus.Faulted),
|
||||
AggregateFaultCount = last.AggregateFaultCount,
|
||||
Properties = last.Properties
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +53,7 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService
|
|||
public async Task<ActivityExecutionStats> GetStatsAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stats = (await GetStatsAsync(workflowInstanceId, [activityNodeId], cancellationToken)).FirstOrDefault();
|
||||
|
||||
|
||||
return stats ?? new ActivityExecutionStats
|
||||
{
|
||||
ActivityNodeId = activityNodeId
|
||||
|
|
|
|||
|
|
@ -62,25 +62,27 @@ public class BackgroundActivityInvoker(
|
|||
var journalDataKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityJournalDataKey(activityNodeId);
|
||||
var bookmarksKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityBookmarksKey(activityNodeId);
|
||||
var scheduledActivitiesKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityScheduledActivitiesKey(activityNodeId);
|
||||
var propsKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityPropertiesKey(activityNodeId);
|
||||
var outcomes = activityExecutionContext.GetBackgroundOutcomes()?.ToList();
|
||||
var completed = activityExecutionContext.GetBackgroundCompleted();
|
||||
var scheduledActivities = activityExecutionContext.GetBackgroundScheduledActivities().ToList();
|
||||
var workflowInstanceId = scheduledBackgroundActivity.WorkflowInstanceId;
|
||||
var outputValues = await activityPropertyLogPersistenceEvaluator.GetPersistableOutputAsync(activityExecutionContext);
|
||||
var properties = new Dictionary<string, object>
|
||||
var bookmarkProps = new Dictionary<string, object>
|
||||
{
|
||||
[scheduledActivitiesKey] = JsonSerializer.Serialize(scheduledActivities),
|
||||
[inputKey] = outputValues,
|
||||
[journalDataKey] = activityExecutionContext.JournalData,
|
||||
[bookmarksKey] = activityExecutionContext.Bookmarks.ToList()
|
||||
[bookmarksKey] = activityExecutionContext.Bookmarks.ToList(),
|
||||
[propsKey] = activityExecutionContext.Properties
|
||||
};
|
||||
|
||||
if (outcomes != null) properties[outcomesKey] = outcomes;
|
||||
if (completed != null) properties[completedKey] = completed;
|
||||
if (outcomes != null) bookmarkProps[outcomesKey] = outcomes;
|
||||
if (completed != null) bookmarkProps[completedKey] = completed;
|
||||
|
||||
var resumeBookmarkOptions = new ResumeBookmarkOptions
|
||||
{
|
||||
Properties = properties,
|
||||
Properties = bookmarkProps,
|
||||
};
|
||||
var enqueuedBookmark = new NewBookmarkQueueItem
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared.Integration\Elsa.Testing.Shared.Integration.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Http\Elsa.Http.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Resilience\Elsa.Resilience.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
using System.Net;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Http;
|
||||
using Elsa.Resilience.Extensions;
|
||||
using Elsa.Resilience.Models;
|
||||
using Elsa.Testing.Shared;
|
||||
using Elsa.Workflows;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Elsa.Resilience.IntegrationTests;
|
||||
|
||||
public class FlowSendHttpRequestResilienceTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly TestRetryAttemptRecorder _recorder = new();
|
||||
|
||||
public FlowSendHttpRequestResilienceTests(ITestOutputHelper output)
|
||||
{
|
||||
var handler = new SequentialStatusHandler([HttpStatusCode.TooManyRequests, HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK]);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Resilience:Strategies:0:$type"] = "HttpResilienceStrategy",
|
||||
["Resilience:Strategies:0:Id"] = "test",
|
||||
["Resilience:Strategies:0:DisplayName"] = "Test",
|
||||
["Resilience:Strategies:0:MaxRetryAttempts"] = "5",
|
||||
["Resilience:Strategies:0:Delay"] = "00:00:00.001"
|
||||
})
|
||||
.Build();
|
||||
|
||||
_services = new TestApplicationBuilder(output)
|
||||
.ConfigureServices(s =>
|
||||
{
|
||||
s.AddSingleton<IConfiguration>(configuration);
|
||||
})
|
||||
.ConfigureElsa(module =>
|
||||
{
|
||||
module.UseHttp(http =>
|
||||
{
|
||||
http.HttpClientBuilder = builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler);
|
||||
});
|
||||
|
||||
module.UseResilience(resilience =>
|
||||
{
|
||||
resilience.WithRetryAttemptRecorder(_ => _recorder);
|
||||
});
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FlowSendHttpRequest retries using selected resilience strategy")]
|
||||
public async Task InvokesResilienceStrategy()
|
||||
{
|
||||
var activity = new FlowSendHttpRequest
|
||||
{
|
||||
Url = new(new Uri("http://localhost/test")),
|
||||
Method = new("GET"),
|
||||
ExpectedStatusCodes = new([200]),
|
||||
CustomProperties =
|
||||
{
|
||||
["resilienceStrategy"] = new ResilienceStrategyConfig
|
||||
{
|
||||
Mode = ResilienceStrategyConfigMode.Identifier,
|
||||
StrategyId = "test"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _services.RunActivityAsync(activity);
|
||||
var statusCode = result.GetActivityOutput<int>(activity, nameof(SendHttpRequestBase.StatusCode));
|
||||
|
||||
Assert.Equal(200, statusCode);
|
||||
Assert.Equal(2, _recorder.Attempts.Count);
|
||||
Assert.Collection(_recorder.Attempts,
|
||||
first => Assert.Equal(0, first.AttemptNumber),
|
||||
second => Assert.Equal(1, second.AttemptNumber));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Elsa.Resilience.IntegrationTests;
|
||||
|
||||
public class SequentialStatusHandler(IEnumerable<HttpStatusCode> codes) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpStatusCode> _codes = new(codes);
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var code = _codes.Count > 0 ? _codes.Dequeue() : _codes.Last();
|
||||
var response = new HttpResponseMessage(code)
|
||||
{
|
||||
Content = new StringContent(string.Empty)
|
||||
};
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using Elsa.Resilience.Entities;
|
||||
using Elsa.Resilience.Models;
|
||||
|
||||
namespace Elsa.Resilience.IntegrationTests;
|
||||
|
||||
public class TestRetryAttemptRecorder : IRetryAttemptRecorder
|
||||
{
|
||||
public IList<RetryAttemptRecord> Attempts { get; } = new List<RetryAttemptRecord>();
|
||||
|
||||
public Task RecordAsync(RecordRetryAttemptsContext context)
|
||||
{
|
||||
foreach (var record in context.Attempts)
|
||||
Attempts.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
global using Xunit;
|
||||
Loading…
Reference in a new issue