diff --git a/Directory.Packages.props b/Directory.Packages.props index c277948ca..fd43a70aa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -86,6 +86,8 @@ + + diff --git a/Elsa.sln b/Elsa.sln index 98441e50b..373517434 100644 --- a/Elsa.sln +++ b/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} diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index 306c906a5..b239fcece 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -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" } ] } diff --git a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj index ef83a5aee..f8246f150 100644 --- a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj +++ b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj @@ -17,8 +17,4 @@ - - - - diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs index c16488325..f34b3a67b 100644 --- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs @@ -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; diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs index 3446d0637..4bc70d80c 100644 --- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs @@ -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(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); + services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecord.cs b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecord.cs index 0b1f928b7..c43313dd0 100644 --- a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecord.cs +++ b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecord.cs @@ -13,22 +13,22 @@ public class ActivityExecutionRecord : Entity /// /// Gets or sets the workflow instance ID. /// - public string WorkflowInstanceId { get; set; } = default!; + public string WorkflowInstanceId { get; set; } = null!; /// /// Gets or sets the activity ID. /// - public string ActivityId { get; set; } = default!; + public string ActivityId { get; set; } = null!; /// /// Gets or sets the activity node ID. /// - public string ActivityNodeId { get; set; } = default!; + public string ActivityNodeId { get; set; } = null!; /// /// The type of the activity. /// - public string ActivityType { get; set; } = default!; + public string ActivityType { get; set; } = null!; /// /// The version of the activity type. @@ -38,7 +38,7 @@ public class ActivityExecutionRecord : Entity /// /// The name of the activity. /// - public string? ActivityName { get; set; } = default!; + public string? ActivityName { get; set; } = null!; /// /// The state of the activity at the time this record is created or last updated. diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs index b8734bc43..56409a15c 100644 --- a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs +++ b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs @@ -47,6 +47,11 @@ public class ActivityExecutionRecordSummary : Entity /// public bool HasBookmarks { get; set; } + /// + /// Gets or sets a collection of properties for the activity execution. + /// + public IDictionary? Properties { get; set; } + /// /// Gets or sets the status of the activity. /// @@ -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 }; } } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionStats.cs b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionStats.cs index 306078897..61d5e212d 100644 --- a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionStats.cs +++ b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionStats.cs @@ -43,5 +43,5 @@ public class ActivityExecutionStats /// /// Gets or sets the total count of faults aggregated from the activity execution and its descendants. /// - public int AggregateFaultCount { get; set; } + public IDictionary? Properties { get; set; } } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs b/src/clients/Elsa.Api.Client/Resources/Resilience/Contracts/IIncidentStrategiesApi.cs similarity index 92% rename from src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs rename to src/clients/Elsa.Api.Client/Resources/Resilience/Contracts/IIncidentStrategiesApi.cs index 089226afc..713b34833 100644 --- a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs +++ b/src/clients/Elsa.Api.Client/Resources/Resilience/Contracts/IIncidentStrategiesApi.cs @@ -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; /// /// Provides operations for managing and retrieving resilience strategies within the application. diff --git a/src/clients/Elsa.Api.Client/Resources/Resilience/Contracts/IRetryAttemptsApi.cs b/src/clients/Elsa.Api.Client/Resources/Resilience/Contracts/IRetryAttemptsApi.cs new file mode 100644 index 000000000..fc25331d0 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Resilience/Contracts/IRetryAttemptsApi.cs @@ -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> ListAsync(string activityInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/ResilienceStrategyConfig.cs similarity index 91% rename from src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs rename to src/clients/Elsa.Api.Client/Resources/Resilience/Models/ResilienceStrategyConfig.cs index 136f3e2bd..2b56bd4f4 100644 --- a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs +++ b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/ResilienceStrategyConfig.cs @@ -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 { diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/ResilienceStrategyConfigMode.cs similarity index 54% rename from src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs rename to src/clients/Elsa.Api.Client/Resources/Resilience/Models/ResilienceStrategyConfigMode.cs index 489730c14..1c55276e2 100644 --- a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs +++ b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/ResilienceStrategyConfigMode.cs @@ -1,4 +1,4 @@ -namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models; +namespace Elsa.Api.Client.Resources.Resilience.Models; public enum ResilienceStrategyConfigMode { diff --git a/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs new file mode 100644 index 000000000..f76518386 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs @@ -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 Details { get; set; } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Contracts/IWorkflowInstancesApi.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Contracts/IWorkflowInstancesApi.cs index a61f6f3fb..b3e071b59 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Contracts/IWorkflowInstancesApi.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Contracts/IWorkflowInstancesApi.cs @@ -34,7 +34,7 @@ public interface IWorkflowInstancesApi /// The number of records to return. /// The cancellation token. [Get("/workflow-instances/{workflowInstanceId}/journal")] - Task> GetJournalAsync(string workflowInstanceId, int? skip = default, int? take = default, CancellationToken cancellationToken = default); + Task> GetJournalAsync(string workflowInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default); /// /// Returns a page of journal records for the specified workflow instance. @@ -45,7 +45,7 @@ public interface IWorkflowInstancesApi /// The number of records to return. /// The cancellation token. [Post("/workflow-instances/{workflowInstanceId}/journal")] - Task> GetFilteredJournalAsync(string workflowInstanceId, GetFilteredJournalRequest? filter, int? skip = default, int? take = default, CancellationToken cancellationToken = default); + Task> GetFilteredJournalAsync(string workflowInstanceId, GetFilteredJournalRequest? filter, int? skip = null, int? take = null, CancellationToken cancellationToken = default); /// /// Returns the execution state of the specified workflow instance. diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ExceptionState.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ExceptionState.cs index 9f4d36092..27f15b69b 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ExceptionState.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ExceptionState.cs @@ -11,7 +11,7 @@ public record ExceptionState(string Type, string Message, string? StackTrace, Ex /// Initializes a new instance of the class. /// [JsonConstructor] - public ExceptionState() : this(default!, default!, default, default) + public ExceptionState() : this(null!, null!, null, null) { } diff --git a/src/modules/Elsa.Common/Models/Page.cs b/src/modules/Elsa.Common/Models/Page.cs index fd9043d0e..cb59b6ab3 100644 --- a/src/modules/Elsa.Common/Models/Page.cs +++ b/src/modules/Elsa.Common/Models/Page.cs @@ -21,4 +21,5 @@ public static class Page /// The type of the items. /// A new instance of the class. public static Page Of(ICollection items, long totalCount) => new(items, totalCount); + public static Page Empty() => new([], 0); } \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs index 092781327..b3aca57f3 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs @@ -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( /// public async Task> FindManySummariesAsync(ActivityExecutionRecordFilter filter, ActivityExecutionRecordOrder 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); } /// public async Task> 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); } /// @@ -142,4 +147,62 @@ public class EFCoreActivityExecutionStore( } private static IQueryable Filter(IQueryable queryable, ActivityExecutionRecordFilter filter) => filter.Apply(queryable); + + private static Expression> 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(record, "SerializedProperties"), + CompletedAt = record.CompletedAt + }; + } + + private IEnumerable Map(IEnumerable 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>(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; } + } } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index d922d1965..898c76365 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -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 CollectRetryDetails(ActivityExecutionContext context, RetryAttempt attempt) + { + if (attempt.Result is not HttpResponseMessage response) + return new Dictionary(); + + return new Dictionary + { + ["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() + }; + } + /// /// Handles the response. /// diff --git a/src/modules/Elsa.Http/Extensions/HttpStatusCodeExtensions.cs b/src/modules/Elsa.Http/Extensions/HttpStatusCodeExtensions.cs new file mode 100644 index 000000000..bbdfceccd --- /dev/null +++ b/src/modules/Elsa.Http/Extensions/HttpStatusCodeExtensions.cs @@ -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 + }; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs index fd621b9b7..668d18863 100644 --- a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs @@ -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; diff --git a/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs b/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs index c346a5773..e466b5ab2 100644 --- a/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs +++ b/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs @@ -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 ExecuteAsync(Func> action) + public Task ConfigurePipeline(ResiliencePipelineBuilder pipelineBuilder, ResilienceContext context) { - AsyncRetryPolicy policy = Policy - .Handle() - .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 + { + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + .HandleResult(response => ((HttpResponseMessage)(object)response!).StatusCode.IsTransientStatusCode()), + MaxRetryAttempts = MaxRetryAttempts, + Delay = Delay, + UseJitter = UseJitter, + BackoffType = BackoffType, + Name = DisplayName + }; + + pipelineBuilder.AddRetry(options); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs index ed51cd4d2..e3c611b2c 100644 --- a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs +++ b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs @@ -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; diff --git a/src/modules/Elsa.Http/Services/DefaultRouteTableUpdater.cs b/src/modules/Elsa.Http/Services/DefaultRouteTableUpdater.cs index 715e31b56..bca546879 100644 --- a/src/modules/Elsa.Http/Services/DefaultRouteTableUpdater.cs +++ b/src/modules/Elsa.Http/Services/DefaultRouteTableUpdater.cs @@ -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; diff --git a/src/modules/Elsa.Http/Services/RouteMatcher.cs b/src/modules/Elsa.Http/Services/RouteMatcher.cs index 4d9de3ae7..f0681272c 100644 --- a/src/modules/Elsa.Http/Services/RouteMatcher.cs +++ b/src/modules/Elsa.Http/Services/RouteMatcher.cs @@ -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; diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs index 599f146bb..80ed32fff 100644 --- a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs @@ -1,8 +1,10 @@ -namespace Elsa.Resilience; +using Polly; + +namespace Elsa.Resilience; public interface IResilienceStrategy { string Id { get; set; } string DisplayName { get; set; } - Task ExecuteAsync(Func> action); + Task ConfigurePipeline(ResiliencePipelineBuilder pipelineBuilder, ResilienceContext context); } \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs index d8b07e376..bc004baf5 100644 --- a/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs @@ -1,5 +1,9 @@ +using Elsa.Resilience.Models; using Elsa.Workflows; namespace Elsa.Resilience; -public interface IResilientActivity : IActivity; \ No newline at end of file +public interface IResilientActivity : IActivity +{ + IDictionary CollectRetryDetails(ActivityExecutionContext context, RetryAttempt attempt); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IRetryAttemptReader.cs b/src/modules/Elsa.Resilience.Core/Contracts/IRetryAttemptReader.cs new file mode 100644 index 000000000..020ff3275 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IRetryAttemptReader.cs @@ -0,0 +1,9 @@ +using Elsa.Common.Models; +using Elsa.Resilience.Entities; + +namespace Elsa.Resilience; + +public interface IRetryAttemptReader +{ + Task> ReadAttemptsAsync(string activityInstanceId, PageArgs? pageArgs = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IRetryAttemptRecorder.cs b/src/modules/Elsa.Resilience.Core/Contracts/IRetryAttemptRecorder.cs new file mode 100644 index 000000000..6a23b2b9b --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IRetryAttemptRecorder.cs @@ -0,0 +1,8 @@ +using Elsa.Resilience.Models; + +namespace Elsa.Resilience; + +public interface IRetryAttemptRecorder +{ + Task RecordAsync(RecordRetryAttemptsContext context); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj index d36195aae..2dcaf7965 100644 --- a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj +++ b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj @@ -12,4 +12,9 @@ + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Entities/RetryAttemptRecord.cs b/src/modules/Elsa.Resilience.Core/Entities/RetryAttemptRecord.cs new file mode 100644 index 000000000..c4247927a --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Entities/RetryAttemptRecord.cs @@ -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 Details { get; set; } = new Dictionary(); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Extensions/ActivityExecutionExtensions.cs b/src/modules/Elsa.Resilience.Core/Extensions/ActivityExecutionExtensions.cs new file mode 100644 index 000000000..a12ccf001 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Extensions/ActivityExecutionExtensions.cs @@ -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); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Models/RecordRetryAttemptsContext.cs b/src/modules/Elsa.Resilience.Core/Models/RecordRetryAttemptsContext.cs new file mode 100644 index 000000000..1feb6f0cb --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Models/RecordRetryAttemptsContext.cs @@ -0,0 +1,6 @@ +using Elsa.Resilience.Entities; +using Elsa.Workflows; + +namespace Elsa.Resilience.Models; + +public record RecordRetryAttemptsContext(ActivityExecutionContext ActivityExecutionContext, ICollection Attempts, CancellationToken CancellationToken); \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Models/RetryAttempt.cs b/src/modules/Elsa.Resilience.Core/Models/RetryAttempt.cs new file mode 100644 index 000000000..717da5406 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Models/RetryAttempt.cs @@ -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(); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs b/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs index 451a791fa..6bf798961 100644 --- a/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs +++ b/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs @@ -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; } diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs index cc1dcaade..8bf2c250f 100644 --- a/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs +++ b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs @@ -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 InvokeAsync(IResilientActivity activity, ActivityExecutionContext context, Func> 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(); + var retries = new List(); + 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(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 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 CreateResiliencePipelineBuilder() + { + var telemetryOptions = new TelemetryOptions(); + telemetryOptions.TelemetryListeners.Add(new RetryTelemetryListener()); + return new ResiliencePipelineBuilder().ConfigureTelemetry(telemetryOptions); } private ResilienceStrategyConfig? GetStrategyConfig(IResilientActivity resilientActivity) @@ -21,4 +89,24 @@ public class ResilientActivityInvoker(IResilienceStrategyConfigEvaluator configE ? null : value.ConvertTo(); } + + private ICollection Map(ActivityExecutionContext activityExecutionContext, IResilientActivity resilientActivity, ICollection 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 + }; + } } \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/RetryTelemetryListener.cs b/src/modules/Elsa.Resilience.Core/Services/RetryTelemetryListener.cs new file mode 100644 index 000000000..0614c6aa2 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/RetryTelemetryListener.cs @@ -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(in TelemetryEventArguments args) + { + if (args.Event.EventName != "OnRetry" || args.Arguments is not OnRetryArguments retryArgs) + return; + + var ctx = args.Context; + + if (!ctx.Properties.TryGetValue(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)activityExecutionContext.TransientProperties[RetryAttempt.RetriesKey]; + records.Add(record); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/VoidRetryAttemptReader.cs b/src/modules/Elsa.Resilience.Core/Services/VoidRetryAttemptReader.cs new file mode 100644 index 000000000..91f33a2b2 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/VoidRetryAttemptReader.cs @@ -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> ReadAttemptsAsync(string activityInstanceId, PageArgs? pageArgs = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(Page.Empty()); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/VoidRetryAttemptRecorder.cs b/src/modules/Elsa.Resilience.Core/Services/VoidRetryAttemptRecorder.cs new file mode 100644 index 000000000..a65103c91 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/VoidRetryAttemptRecorder.cs @@ -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; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Elsa.Resilience.csproj b/src/modules/Elsa.Resilience/Elsa.Resilience.csproj index 6a2f5cf12..1442fd235 100644 --- a/src/modules/Elsa.Resilience/Elsa.Resilience.csproj +++ b/src/modules/Elsa.Resilience/Elsa.Resilience.csproj @@ -10,6 +10,11 @@ + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs b/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs index 5398e10e0..87f06d7a0 100644 --- a/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs +++ b/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs @@ -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) diff --git a/src/modules/Elsa.Resilience/Endpoints/Retries/List/Endpoint.cs b/src/modules/Elsa.Resilience/Endpoints/Retries/List/Endpoint.cs new file mode 100644 index 000000000..98de4938a --- /dev/null +++ b/src/modules/Elsa.Resilience/Endpoints/Retries/List/Endpoint.cs @@ -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("skip", false); + var take = Query("take", false); + var pageArgs = skip == null && take == null ? null : PageArgs.FromRange(skip, take); + var activityInstanceId = Route("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(page); + await SendOkAsync(response, ct); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Endpoint.cs b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Endpoint.cs new file mode 100644 index 000000000..17f9fd0b3 --- /dev/null +++ b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Endpoint.cs @@ -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 +{ + 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(codesParam)!; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Models.cs b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Models.cs new file mode 100644 index 000000000..66ecce0ec --- /dev/null +++ b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Models.cs @@ -0,0 +1,3 @@ +namespace Elsa.Resilience.Endpoints.SimulateResponse; + +public record SimulatedResponse(string Message); \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/StatusCodeMessageLookup.cs b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/StatusCodeMessageLookup.cs new file mode 100644 index 000000000..f83d1e7f4 --- /dev/null +++ b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/StatusCodeMessageLookup.cs @@ -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 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", + }; +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs b/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs index 740d42e9d..ee1ef144b 100644 --- a/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs +++ b/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs @@ -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 _retryAttemptRecorder = sp => sp.GetRequiredService(); + private Func _retryAttemptReader = sp => sp.GetRequiredService(); + public ResilienceFeature AddResilienceStrategyType() 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() + .WithRetryAttemptReader(); + } + + public ResilienceFeature WithVoidRetryAttemptRecorder() + { + return WithRetryAttemptRecorder() + .WithRetryAttemptReader(); + } + + public ResilienceFeature WithRetryAttemptRecorder() => WithRetryAttemptRecorder(sp => (IRetryAttemptRecorder)ActivatorUtilities.CreateInstance(sp)!); + public ResilienceFeature WithRetryAttemptReader() => WithRetryAttemptReader(sp => (IRetryAttemptReader)ActivatorUtilities.CreateInstance(sp)!); + + public ResilienceFeature WithRetryAttemptRecorder(Func recorder) + { + _retryAttemptRecorder = recorder; + return this; + } + + public ResilienceFeature WithRetryAttemptReader(Func reader) + { + _retryAttemptReader = reader; + return this; + } + public override void Configure() { Module.AddFastEndpointsAssembly(); @@ -38,6 +69,13 @@ public class ResilienceFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddSingleton(VoidRetryAttemptRecorder.Instance) + .AddSingleton(VoidRetryAttemptReader.Instance) + .AddScoped() + .AddScoped() + .AddScoped(_retryAttemptRecorder) + .AddScoped(_retryAttemptReader) + .AddHandlersFrom(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Handlers/PropagateRetryAttemptedFlag.cs b/src/modules/Elsa.Resilience/Handlers/PropagateRetryAttemptedFlag.cs new file mode 100644 index 000000000..d56d6ffc6 --- /dev/null +++ b/src/modules/Elsa.Resilience/Handlers/PropagateRetryAttemptedFlag.cs @@ -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 +{ + 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; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Recorders/ActivityExecutionContextRetryAttemptReader.cs b/src/modules/Elsa.Resilience/Recorders/ActivityExecutionContextRetryAttemptReader.cs new file mode 100644 index 000000000..2f9f95779 --- /dev/null +++ b/src/modules/Elsa.Resilience/Recorders/ActivityExecutionContextRetryAttemptReader.cs @@ -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> 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 retryAttempts) + return Page.Empty(); + + return retryAttempts.Paginate(pageArgs); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Recorders/ActivityExecutionContextRetryAttemptRecorder.cs b/src/modules/Elsa.Resilience/Recorders/ActivityExecutionContextRetryAttemptRecorder.cs new file mode 100644 index 000000000..640d1a66f --- /dev/null +++ b/src/modules/Elsa.Resilience/Recorders/ActivityExecutionContextRetryAttemptRecorder.cs @@ -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; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Report/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Report/Endpoint.cs index 42e49fcfb..e8804937f 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Report/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Report/Endpoint.cs @@ -32,7 +32,7 @@ internal class Report : ElsaEndpoint { var stats = (await _activityExecutionStatsService.GetStatsAsync(request.WorkflowInstanceId, request.ActivityNodeIds, cancellationToken)).ToList(); - return new Response + return new() { Stats = stats }; diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.ExecutionLogEntry.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.ExecutionLogEntry.cs index 137f372fd..2f70551ea 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.ExecutionLogEntry.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.ExecutionLogEntry.cs @@ -12,17 +12,17 @@ public partial class WorkflowExecutionContext /// The message of the event. /// Any contextual data related to this event. /// Returns the created . - 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, diff --git a/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs b/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs index 963ba31ed..1634207aa 100644 --- a/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs +++ b/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs @@ -52,6 +52,11 @@ public class ActivityExecutionRecordSummary : Entity /// Gets or sets the status of the activity. /// public ActivityStatus Status { get; set; } + + /// + /// Gets or sets a dictionary of key-value pairs representing additional properties for the activity execution record summary. + /// + public IDictionary? Properties { get; set; } /// /// 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 }; } diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs index db98a7ac3..e744870a3 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs @@ -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 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"; /// @@ -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>(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) { diff --git a/src/modules/Elsa.Workflows.Runtime/Models/ActivityExecutionStats.cs b/src/modules/Elsa.Workflows.Runtime/Models/ActivityExecutionStats.cs index 859efc83f..2307bbdcd 100644 --- a/src/modules/Elsa.Workflows.Runtime/Models/ActivityExecutionStats.cs +++ b/src/modules/Elsa.Workflows.Runtime/Models/ActivityExecutionStats.cs @@ -44,4 +44,9 @@ public class ActivityExecutionStats /// Gets or sets the total count of faults aggregated from the activity execution and its descendants. /// public int AggregateFaultCount { get; set; } + + /// + /// Gets or sets additional properties related to the activity execution. + /// + public IDictionary? Properties { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/BackgroundActivityExecutionCompleted.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/BackgroundActivityExecutionCompleted.cs new file mode 100644 index 000000000..80d72eb8a --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/BackgroundActivityExecutionCompleted.cs @@ -0,0 +1,5 @@ +using Elsa.Mediator.Contracts; + +namespace Elsa.Workflows.Runtime.Notifications; + +public record BackgroundActivityExecutionCompleted(ActivityExecutionContext ActivityExecutionContext) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionService.cs b/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionService.cs index 2a57bbfab..0235c7d57 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionService.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionService.cs @@ -16,7 +16,7 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService { _store = store; } - + /// public async Task> GetStatsAsync(string workflowInstanceId, IEnumerable activityNodeIds, CancellationToken cancellationToken = default) { @@ -28,18 +28,24 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService var order = new ActivityExecutionRecordOrder(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 GetStatsAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) { var stats = (await GetStatsAsync(workflowInstanceId, [activityNodeId], cancellationToken)).FirstOrDefault(); - + return stats ?? new ActivityExecutionStats { ActivityNodeId = activityNodeId diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs b/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs index 13ff7f8a0..49c37e7b2 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BackgroundActivityInvoker.cs @@ -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 + var bookmarkProps = new Dictionary { [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 { diff --git a/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj b/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj new file mode 100644 index 000000000..95419bcf8 --- /dev/null +++ b/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + + + + + + + + + + + diff --git a/test/integration/Elsa.Resilience.IntegrationTests/FlowSendHttpRequestResilienceTests.cs b/test/integration/Elsa.Resilience.IntegrationTests/FlowSendHttpRequestResilienceTests.cs new file mode 100644 index 000000000..421809094 --- /dev/null +++ b/test/integration/Elsa.Resilience.IntegrationTests/FlowSendHttpRequestResilienceTests.cs @@ -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 + { + ["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(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(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)); + } +} diff --git a/test/integration/Elsa.Resilience.IntegrationTests/SequentialStatusHandler.cs b/test/integration/Elsa.Resilience.IntegrationTests/SequentialStatusHandler.cs new file mode 100644 index 000000000..c12392b85 --- /dev/null +++ b/test/integration/Elsa.Resilience.IntegrationTests/SequentialStatusHandler.cs @@ -0,0 +1,19 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace Elsa.Resilience.IntegrationTests; + +public class SequentialStatusHandler(IEnumerable codes) : HttpMessageHandler +{ + private readonly Queue _codes = new(codes); + protected override Task 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); + } +} diff --git a/test/integration/Elsa.Resilience.IntegrationTests/TestRetryAttemptRecorder.cs b/test/integration/Elsa.Resilience.IntegrationTests/TestRetryAttemptRecorder.cs new file mode 100644 index 000000000..819c888bd --- /dev/null +++ b/test/integration/Elsa.Resilience.IntegrationTests/TestRetryAttemptRecorder.cs @@ -0,0 +1,16 @@ +using Elsa.Resilience.Entities; +using Elsa.Resilience.Models; + +namespace Elsa.Resilience.IntegrationTests; + +public class TestRetryAttemptRecorder : IRetryAttemptRecorder +{ + public IList Attempts { get; } = new List(); + + public Task RecordAsync(RecordRetryAttemptsContext context) + { + foreach (var record in context.Attempts) + Attempts.Add(record); + return Task.CompletedTask; + } +} diff --git a/test/integration/Elsa.Resilience.IntegrationTests/Usings.cs b/test/integration/Elsa.Resilience.IntegrationTests/Usings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/test/integration/Elsa.Resilience.IntegrationTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit;