diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index af30c1b665..7cfca83b33 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -42,6 +42,7 @@ using Elsa.Tenants.Extensions; using Elsa.Workflows; using Elsa.Workflows.Api; +using Elsa.Workflows.CommitStates.Strategies; using Elsa.Workflows.LogPersistence; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Compression; @@ -215,6 +216,11 @@ { workflows.WithDefaultWorkflowExecutionPipeline(pipeline => pipeline.UseWorkflowExecutionTracing()); workflows.WithDefaultActivityExecutionPipeline(pipeline => pipeline.UseActivityExecutionTracing()); + workflows.UseCommitStrategies(strategies => + { + strategies.AddStandardStrategies(); + strategies.Add("Every 10 seconds", new PeriodicWorkflowStrategy(TimeSpan.FromSeconds(10))); + }); }) .UseWorkflowManagement(management => { diff --git a/src/apps/Elsa.Server.Web/SampleWorkflow.cs b/src/apps/Elsa.Server.Web/SampleWorkflow.cs new file mode 100644 index 0000000000..4f031c47d0 --- /dev/null +++ b/src/apps/Elsa.Server.Web/SampleWorkflow.cs @@ -0,0 +1,25 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Extensions; +using Elsa.Workflows.CommitStates.Strategies; + +namespace Elsa.Server.Web; + +public class SampleWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + builder.WorkflowOptions.CommitStrategyName = "Every 10 seconds"; + builder.Root = new Sequence + { + Activities = + { + new WriteLine("Commit before executing").SetCommitStrategy(nameof(ExecutingActivityStrategy)), + new WriteLine("Commit after executing").SetCommitStrategy(nameof(ExecutedActivityStrategy)), + new WriteLine("Commit before & after executing").SetCommitStrategy(nameof(CommitAlwaysActivityStrategy)), + new WriteLine("Commit only based on the workflow commit options").SetCommitStrategy(nameof(DefaultActivityStrategy)), + new WriteLine("Never commit the workflow when this activity is about to execute or has executed").SetCommitStrategy(nameof(CommitNeverActivityStrategy)), + } + }; + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs index 6ae48eab80..4990ea81a7 100644 --- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; using Elsa.Api.Client.Shared.Models; namespace Elsa.Api.Client.Extensions; @@ -182,4 +183,14 @@ public static void SetDisplayText(this JsonObject activity, string? value) /// Sets a value indicating whether the specified activity can trigger the workflow. /// public static void SetRunAsynchronously(this JsonObject activity, bool value) => activity.SetProperty(JsonValue.Create(value), "customProperties", "runAsynchronously"); + + /// + /// Gets the commit state behavior for the specified activity. + /// + public static string? GetCommitStrategy(this JsonObject activity) => activity.TryGetProperty("customProperties", "commitStrategyName"); + + /// + /// Sets the commit state behavior for the specified activity. + /// + public static void SetCommitStrategy(this JsonObject activity, string? name) => activity.SetProperty(JsonValue.Create(name), "customProperties", "commitStrategyName"); } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs index e8bb1afe3f..3e23b2e455 100644 --- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs @@ -2,6 +2,7 @@ using Elsa.Api.Client.Resources.ActivityDescriptorOptions.Contracts; using Elsa.Api.Client.Resources.ActivityDescriptors.Contracts; using Elsa.Api.Client.Resources.ActivityExecutions.Contracts; +using Elsa.Api.Client.Resources.CommitStrategies.Contracts; using Elsa.Api.Client.Resources.Features.Contracts; using Elsa.Api.Client.Resources.Identity.Contracts; using Elsa.Api.Client.Resources.IncidentStrategies.Contracts; @@ -73,6 +74,7 @@ public static IServiceCollection AddDefaultApiClients(this IServiceCollection se services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); + services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); diff --git a/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs index a1612f9c22..b65fcf394f 100644 --- a/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs @@ -15,52 +15,43 @@ public static bool IsActivity(this JsonObject obj) { return obj.ContainsKey("type") && obj.ContainsKey("id") && obj.ContainsKey("version"); } - + /// /// Serializes the specified value to a . /// /// The value to serialize. /// The to use. /// A representing the specified value. - public static JsonNode SerializeToNode(this object value, JsonSerializerOptions? options = default) + public static JsonNode SerializeToNode(this object value, JsonSerializerOptions? options = null) { - options ??= new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - + options ??= new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + return JsonSerializer.SerializeToNode(value, options)!; } - + /// /// Serializes the specified value to a . /// /// The value to serialize. /// The to use. /// A representing the specified value. - public static JsonArray SerializeToArray(this object value, JsonSerializerOptions? options = default) + public static JsonArray SerializeToArray(this object value, JsonSerializerOptions? options = null) { - options ??= new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - + options ??= new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + return JsonSerializer.SerializeToNode(value, options)!.AsArray(); } - + /// /// Serializes the specified value to a . /// /// The value to serialize. /// The to use. /// A representing the specified value. - public static JsonArray SerializeToArray(this IEnumerable value, JsonSerializerOptions? options = default) + public static JsonArray SerializeToArray(this IEnumerable value, JsonSerializerOptions? options = null) { - options ??= new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - + options ??= new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + return JsonSerializer.SerializeToNode(value, options)!.AsArray(); } @@ -71,19 +62,23 @@ public static JsonArray SerializeToArray(this IEnumerable value, JsonSeria /// The to use. /// The type to deserialize to. /// The deserialized value. - public static T Deserialize(this JsonNode value, JsonSerializerOptions? options = default) + public static T Deserialize(this JsonNode value, JsonSerializerOptions? options = null) { - options ??= new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - + options ??= new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + if (value is JsonObject jsonObject) return JsonSerializer.Deserialize(jsonObject, options)!; if (value is JsonArray jsonArray) return JsonSerializer.Deserialize(jsonArray, options)!; + if (typeof(T).IsEnum || (Nullable.GetUnderlyingType(typeof(T))?.IsEnum ?? false)) + { + if (value.GetValueKind() == JsonValueKind.Null) + return default!; + return (T)Enum.Parse(Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T), value.ToString()); + } + if (value is JsonValue jsonValue) return jsonValue.GetValue(); @@ -101,7 +96,7 @@ public static void SetProperty(this JsonObject model, JsonNode? value, params st model = GetPropertyContainer(model, path); model[path.Last()] = value?.SerializeToNode(); } - + /// /// Sets the property value of the specified model. /// @@ -113,7 +108,7 @@ public static void SetProperty(this JsonObject model, JsonArray? value, params s model = GetPropertyContainer(model, path); model[path.Last()] = value?.SerializeToNode(); } - + /// /// Sets the property value of the specified model. /// @@ -125,7 +120,7 @@ public static void SetProperty(this JsonObject model, IEnumerable valu model = GetPropertyContainer(model, path); model[path.Last()] = new JsonArray(value.Select(x => x.SerializeToNode()).ToArray()); } - + /// /// Gets the property value of the specified model. /// @@ -139,7 +134,7 @@ public static void SetProperty(this JsonObject model, IEnumerable valu foreach (var prop in path.SkipLast(1)) { if (currentModel[prop] is not JsonObject value) - return default; + return null; currentModel = value; } @@ -147,6 +142,25 @@ public static void SetProperty(this JsonObject model, IEnumerable valu return currentModel[path.Last()]; } + /// + /// Gets the property value of the specified model. + /// + /// The model to get the property value from. + /// The path to the property. + /// The type to deserialize to. + /// The property value. + public static T? TryGetProperty(this JsonObject model, params string[] path) + { + try + { + return model.GetProperty(path); + } + catch (Exception e) + { + return default; + } + } + /// /// Gets the property value of the specified model. /// @@ -173,7 +187,7 @@ public static void SetProperty(this JsonObject model, IEnumerable valu var property = GetProperty(model, path); return property != null ? property.Deserialize(options) : default; } - + /// /// Returns the property container of the specified model. /// @@ -190,5 +204,4 @@ private static JsonObject GetPropertyContainer(this JsonObject model, params str return model; } - } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/CommitStrategies/Contracts/IIncidentStrategiesApi.cs b/src/clients/Elsa.Api.Client/Resources/CommitStrategies/Contracts/IIncidentStrategiesApi.cs new file mode 100644 index 0000000000..cfee71fccc --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/CommitStrategies/Contracts/IIncidentStrategiesApi.cs @@ -0,0 +1,25 @@ +using Elsa.Api.Client.Resources.CommitStrategies.Models; +using Elsa.Api.Client.Shared.Models; +using Refit; + +namespace Elsa.Api.Client.Resources.CommitStrategies.Contracts; + +/// +/// Represents a client for the commit strategies API. +/// +public interface ICommitStrategiesApi +{ + /// + /// Lists workflow commit strategies. + /// + /// A list response containing activity commit strategy descriptors and their count. + [Get("/descriptors/commit-strategies/workflows")] + Task> ListWorkflowStrategiesAsync(CancellationToken cancellationToken = default); + + /// + /// Lists activity commit strategies. + /// + /// A list response containing activity commit strategy descriptors and their count. + [Get("/descriptors/commit-strategies/activities")] + Task> ListActivityStrategiesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/CommitStrategies/Models/CommitStrategyDescriptor.cs b/src/clients/Elsa.Api.Client/Resources/CommitStrategies/Models/CommitStrategyDescriptor.cs new file mode 100644 index 0000000000..ad63d59310 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/CommitStrategies/Models/CommitStrategyDescriptor.cs @@ -0,0 +1,7 @@ +namespace Elsa.Api.Client.Resources.CommitStrategies.Models; + +/// +/// Represents a descriptor for a commit strategy, containing information such as its technical name, +/// display name, and description. +/// +public record CommitStrategyDescriptor(string Name, string DisplayName, string Description); \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowCommitStateOptions.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowCommitStateOptions.cs new file mode 100644 index 0000000000..8e14345c65 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowCommitStateOptions.cs @@ -0,0 +1,19 @@ +namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Models; + +public class WorkflowCommitStateOptions +{ + /// + /// Commit workflow state before the workflow starts. + /// + public bool Starting { get; set; } + + /// + /// Commit workflow state before an activity executes, unless the activity is configured to not commit state. + /// + public bool ActivityExecuting { get; set; } + + /// + /// Commit workflow state after an activity executes, unless the activity is configured to not commit state. + /// + public bool ActivityExecuted { get; set; } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowOptions.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowOptions.cs index ec28ac197f..07b5c882df 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowOptions.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowOptions.cs @@ -29,4 +29,9 @@ public class WorkflowOptions /// The type of IIncidentStrategy to use when a fault occurs in the workflow. /// public string? IncidentStrategyType { get; set; } + + /// + /// The options for committing workflow state. + /// + public string? CommitStrategyName { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/Store.cs b/src/modules/Elsa.EntityFrameworkCore.Common/Store.cs index d5b8fdd6c6..3387e912aa 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/Store.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/Store.cs @@ -79,9 +79,13 @@ public async Task AddManyAsync( Func? onSaving = default, CancellationToken cancellationToken = default) { - await using var dbContext = await CreateDbContextAsync(cancellationToken); var entityList = entities.ToList(); + if (entityList.Count == 0) + return; + + await using var dbContext = await CreateDbContextAsync(cancellationToken); + if (onSaving != null) { var savingTasks = entityList.Select(entity => onSaving(dbContext, entity, cancellationToken).AsTask()).ToList(); @@ -162,9 +166,13 @@ public async Task SaveManyAsync( Func? onSaving = default, CancellationToken cancellationToken = default) { - await using var dbContext = await CreateDbContextAsync(cancellationToken); var entityList = entities.ToList(); + if (entityList.Count == 0) + return; + + await using var dbContext = await CreateDbContextAsync(cancellationToken); + if (onSaving != null) { var savingTasks = entityList.Select(entity => onSaving(dbContext, entity, cancellationToken).AsTask()).ToList(); diff --git a/src/modules/Elsa.Expressions/Models/ExpressionExecutionContext.cs b/src/modules/Elsa.Expressions/Models/ExpressionExecutionContext.cs index 1c5ff28065..3ca25e6931 100644 --- a/src/modules/Elsa.Expressions/Models/ExpressionExecutionContext.cs +++ b/src/modules/Elsa.Expressions/Models/ExpressionExecutionContext.cs @@ -9,8 +9,9 @@ namespace Elsa.Expressions.Models; public class ExpressionExecutionContext( IServiceProvider serviceProvider, MemoryRegister memory, - ExpressionExecutionContext? parentContext = default, - IDictionary? transientProperties = default, + ExpressionExecutionContext? parentContext = null, + IDictionary? transientProperties = null, + Action? onChange = null, CancellationToken cancellationToken = default) { /// @@ -54,7 +55,7 @@ public class ExpressionExecutionContext( public bool TryGetBlock(MemoryBlockReference blockReference, out MemoryBlock block) { var b = GetBlockInternal(blockReference); - block = b ?? default!; + block = b ?? null!; return b != null; } @@ -79,7 +80,7 @@ public bool TryGet(MemoryBlockReference blockReference, out object? value) return true; } - value = default; + value = null; return false; } @@ -96,16 +97,17 @@ public bool TryGet(MemoryBlockReference blockReference, out object? value) /// /// Sets the value of the memory block pointed to by the specified memory block reference. /// - public void Set(Func blockReference, object? value, Action? configure = default) => Set(blockReference(), value, configure); + public void Set(Func blockReference, object? value, Action? configure = null) => Set(blockReference(), value, configure); /// /// Sets the value of the memory block pointed to by the specified memory block reference. /// - public void Set(MemoryBlockReference blockReference, object? value, Action? configure = default) + public void Set(MemoryBlockReference blockReference, object? value, Action? configure = null) { var block = GetBlockInternal(blockReference) ?? Memory.Declare(blockReference); block.Value = value; configure?.Invoke(block); + onChange?.Invoke(); } /// diff --git a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj index 4fff95dc9a..94828c0a30 100644 --- a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj +++ b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Activities/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Activities/List/Endpoint.cs new file mode 100644 index 0000000000..85fdcdcd08 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Activities/List/Endpoint.cs @@ -0,0 +1,29 @@ +using Elsa.Abstractions; +using Elsa.Models; +using Elsa.Workflows.CommitStates; + +namespace Elsa.Workflows.Api.Endpoints.CommitStrategies.Activities.List; + +/// +/// Represents an API endpoint that provides a list of registered workflow commit strategies. +/// +/// +/// This class is an implementation of an endpoint that retrieves a collection of workflow commit strategy registrations +/// from a provided registry and returns them in a unified response. +/// +internal class List(ICommitStrategyRegistry registry) : ElsaEndpointWithoutRequest> +{ + public override void Configure() + { + Get("/descriptors/commit-strategies/activities"); + ConfigurePermissions("read:commit-strategies"); + } + + public override Task> ExecuteAsync(CancellationToken cancellationToken) + { + var registrations = registry.ListActivityStrategyRegistrations().ToList(); + var descriptors = CommitStrategyDescriptor.FromStrategyMetadata(registrations.Select(x => x.Metadata)).ToList(); + var response =new ListResponse(descriptors); + return Task.FromResult(response); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Models.cs new file mode 100644 index 0000000000..0af9b5e50e --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Models.cs @@ -0,0 +1,16 @@ +using Elsa.Workflows.CommitStates; + +namespace Elsa.Workflows.Api.Endpoints.CommitStrategies; + +internal record CommitStrategyDescriptor(string Name, string DisplayName, string Description) +{ + public static CommitStrategyDescriptor FromStrategyMetadata(CommitStrategyMetadata metadata) + { + return new(metadata.Name, metadata.DisplayName, metadata.Description); + } + + public static IEnumerable FromStrategyMetadata(IEnumerable metadata) + { + return metadata.Select(FromStrategyMetadata); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Workflows/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Workflows/List/Endpoint.cs new file mode 100644 index 0000000000..e7f3fc327e --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/CommitStrategies/Workflows/List/Endpoint.cs @@ -0,0 +1,29 @@ +using Elsa.Abstractions; +using Elsa.Models; +using Elsa.Workflows.CommitStates; + +namespace Elsa.Workflows.Api.Endpoints.CommitStrategies.Workflows.List; + +/// +/// Represents an API endpoint that provides a list of registered workflow commit strategies. +/// +/// +/// This class is an implementation of an endpoint that retrieves a collection of workflow commit strategy registrations +/// from a provided registry and returns them in a unified response. +/// +internal class List(ICommitStrategyRegistry registry) : ElsaEndpointWithoutRequest> +{ + public override void Configure() + { + Get("/descriptors/commit-strategies/workflows"); + ConfigurePermissions("read:commit-strategies"); + } + + public override Task> ExecuteAsync(CancellationToken cancellationToken) + { + var registrations = registry.ListWorkflowStrategyRegistrations().ToList(); + var descriptors = CommitStrategyDescriptor.FromStrategyMetadata(registrations.Select(x => x.Metadata)).ToList(); + var response =new ListResponse(descriptors); + return Task.FromResult(response); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs b/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs index ad52a70a33..3be6601252 100644 --- a/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs +++ b/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs @@ -73,6 +73,13 @@ public bool RunAsynchronously get => this.GetRunAsynchronously(); set => this.SetRunAsynchronously(value); } + + [JsonIgnore] + public string? CommitStrategy + { + get => this.GetCommitStrategy(); + set => this.SetCommitStrategy(value); + } /// [JsonConverter(typeof(PolymorphicObjectConverterFactory))] diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/CommitStrategiesFeature.cs b/src/modules/Elsa.Workflows.Core/CommitStates/CommitStrategiesFeature.cs new file mode 100644 index 0000000000..0e43d71f5c --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/CommitStrategiesFeature.cs @@ -0,0 +1,104 @@ +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Workflows.CommitStates.Strategies; +using Elsa.Workflows.CommitStates.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.CommitStates; + +public class CommitStrategiesFeature(IModule module) : FeatureBase(module) +{ + public void AddStandardStrategies() + { + // Workflow commit strategies. + Add(new DefaultWorkflowStrategy()); + Add(new WorkflowExecutingWorkflowStrategy()); + Add(new WorkflowExecutedWorkflowStrategy()); + Add(new ActivityExecutingWorkflowStrategy()); + Add(new ActivityExecutedWorkflowStrategy()); + + // Activity commit strategies. + Add(new DefaultActivityStrategy()); + Add(new CommitAlwaysActivityStrategy()); + Add(new CommitNeverActivityStrategy()); + Add(new ExecutingActivityStrategy()); + Add(new ExecutedActivityStrategy()); + } + + public void Add(IWorkflowCommitStrategy strategy) + { + var registration = ObjectRegistrationFactory.Describe(strategy); + Add(registration); + } + + public void Add(string displayName, IWorkflowCommitStrategy strategy) + { + var registration = ObjectRegistrationFactory.Describe(strategy); + registration.Metadata.DisplayName = displayName; + Add(registration); + } + + public void Add(string displayName, string description, IWorkflowCommitStrategy strategy) + { + var registration = ObjectRegistrationFactory.Describe(strategy); + registration.Metadata.DisplayName = displayName; + registration.Metadata.Description = description; + Add(registration); + } + + public void Add(string name, string displayName, string description, IWorkflowCommitStrategy strategy) + { + var registration = ObjectRegistrationFactory.Describe(strategy); + registration.Metadata.Name = name; + registration.Metadata.DisplayName = displayName; + registration.Metadata.Description = description; + Add(registration); + } + + public void Add(WorkflowCommitStrategyRegistration registration) + { + Services.Configure(options => options.WorkflowCommitStrategies[registration.Metadata.Name] = registration); + } + + public void Add(IActivityCommitStrategy strategy) + { + var registration = ObjectRegistrationFactory.Describe(strategy); + Add(registration); + } + + public void Add(string displayName, IActivityCommitStrategy strategy) + { + var registration = ObjectRegistrationFactory.Describe(strategy); + registration.Metadata.DisplayName = displayName; + Add(registration); + } + + public void Add(string displayName, string description, IActivityCommitStrategy strategy) + { + var registration = ObjectRegistrationFactory.Describe(strategy); + registration.Metadata.DisplayName = displayName; + registration.Metadata.Description = description; + Add(registration); + } + + public void Add(string name, string displayName, string description, IActivityCommitStrategy strategy) + { + var registration = ObjectRegistrationFactory.Describe(strategy); + registration.Metadata.Name = name; + registration.Metadata.DisplayName = displayName; + registration.Metadata.Description = description; + Add(registration); + } + + public void Add(ActivityCommitStrategyRegistration registration) + { + Services.Configure(options => options.ActivityCommitStrategies[registration.Metadata.Name] = registration); + } + + public override void Apply() + { + Services.AddSingleton(); + Services.AddStartupTask(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/IActivityCommitStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/IActivityCommitStrategy.cs new file mode 100644 index 0000000000..b648a46701 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/IActivityCommitStrategy.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows.CommitStates; + +public interface IActivityCommitStrategy +{ + CommitAction ShouldCommit(ActivityCommitStateStrategyContext context); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/ICommitStateHandler.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/ICommitStateHandler.cs similarity index 57% rename from src/modules/Elsa.Workflows.Core/Contracts/ICommitStateHandler.cs rename to src/modules/Elsa.Workflows.Core/CommitStates/Contracts/ICommitStateHandler.cs index 3d24ee6fdd..175f42828e 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/ICommitStateHandler.cs +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/ICommitStateHandler.cs @@ -1,8 +1,9 @@ using Elsa.Workflows.State; -namespace Elsa.Workflows; +namespace Elsa.Workflows.CommitStates; public interface ICommitStateHandler { + Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, CancellationToken cancellationToken = default); Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, WorkflowState workflowState, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/ICommitStrategyRegistry.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/ICommitStrategyRegistry.cs new file mode 100644 index 0000000000..92a57376c8 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/ICommitStrategyRegistry.cs @@ -0,0 +1,11 @@ +namespace Elsa.Workflows.CommitStates; + +public interface ICommitStrategyRegistry +{ + IEnumerable ListWorkflowStrategyRegistrations(); + IEnumerable ListActivityStrategyRegistrations(); + void RegisterStrategy(WorkflowCommitStrategyRegistration registration); + void RegisterStrategy(ActivityCommitStrategyRegistration registration); + IWorkflowCommitStrategy? FindWorkflowStrategy(string name); + IActivityCommitStrategy? FindActivityStrategy(string name); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/IWorkflowCommitStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/IWorkflowCommitStrategy.cs new file mode 100644 index 0000000000..074d4c8574 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Contracts/IWorkflowCommitStrategy.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows.CommitStates; + +public interface IWorkflowCommitStrategy +{ + CommitAction ShouldCommit(WorkflowCommitStateStrategyContext context); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Extensions/ModuleExtensions.cs new file mode 100644 index 0000000000..9d2bf94c50 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Extensions/ModuleExtensions.cs @@ -0,0 +1,15 @@ +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class WorkflowsFeatureCommitStateExtensions +{ + public static WorkflowsFeature UseCommitStrategies(this WorkflowsFeature workflowsFeature, Action? configure = null) + { + workflowsFeature.Module.Use(configure); + return workflowsFeature; + } + +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Helpers/ObjectMetadataDescriber.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Helpers/ObjectMetadataDescriber.cs new file mode 100644 index 0000000000..817ae09080 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Helpers/ObjectMetadataDescriber.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using System.Reflection; +using Humanizer; + +namespace Elsa.Workflows.CommitStates; + +public static class ObjectMetadataDescriber +{ + public static CommitStrategyMetadata Describe(Type strategyType) + { + var suffix = strategyType.IsAssignableTo(typeof(IActivityCommitStrategy)) ? "ActivityStrategy" : "WorkflowStrategy"; + var name = strategyType.Name.Replace(suffix, ""); + var displayName = strategyType.GetCustomAttribute()?.DisplayName ?? strategyType.Name.Replace("CommitStrategy", "").Humanize(); + var description = strategyType.GetCustomAttribute()?.Description ?? string.Empty; + + return new() + { + Name = name, + DisplayName = displayName, + Description = description + }; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Helpers/ObjectRegistrationFactory.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Helpers/ObjectRegistrationFactory.cs new file mode 100644 index 0000000000..d06a8ae04c --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Helpers/ObjectRegistrationFactory.cs @@ -0,0 +1,16 @@ +namespace Elsa.Workflows.CommitStates; + +public static class ObjectRegistrationFactory +{ + public static WorkflowCommitStrategyRegistration Describe(IWorkflowCommitStrategy strategy) + { + var metadata = ObjectMetadataDescriber.Describe(strategy.GetType()); + return new(strategy, metadata); + } + + public static ActivityCommitStrategyRegistration Describe(IActivityCommitStrategy strategy) + { + var metadata = ObjectMetadataDescriber.Describe(strategy.GetType()); + return new(strategy, metadata); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityCommitStateStrategyContext.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityCommitStateStrategyContext.cs new file mode 100644 index 0000000000..bde323f1ec --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityCommitStateStrategyContext.cs @@ -0,0 +1,3 @@ +namespace Elsa.Workflows.CommitStates; + +public record ActivityCommitStateStrategyContext(ActivityExecutionContext ActivityExecutionContext, ActivityLifetimeEvent LifetimeEvent); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityCommitStrategyRegistration.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityCommitStrategyRegistration.cs new file mode 100644 index 0000000000..a5bb7b997f --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityCommitStrategyRegistration.cs @@ -0,0 +1,14 @@ +namespace Elsa.Workflows.CommitStates; + +public class ActivityCommitStrategyRegistration : ObjectRegistration +{ + public ActivityCommitStrategyRegistration() + { + } + + public ActivityCommitStrategyRegistration(IActivityCommitStrategy strategy, CommitStrategyMetadata metadata) + { + Strategy = strategy; + Metadata = metadata; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityLifetimeEvent.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityLifetimeEvent.cs new file mode 100644 index 0000000000..9a2643416c --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityLifetimeEvent.cs @@ -0,0 +1,7 @@ +namespace Elsa.Workflows.CommitStates; + +public enum ActivityLifetimeEvent +{ + ActivityExecuting, + ActivityExecuted +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityStrategyDescriptor.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityStrategyDescriptor.cs new file mode 100644 index 0000000000..8b1c266ccb --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ActivityStrategyDescriptor.cs @@ -0,0 +1,3 @@ +namespace Elsa.Workflows.CommitStates; + +public record ActivityStrategyDescriptor(string Name, string Description, IActivityCommitStrategy Strategy); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/CommitAction.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/CommitAction.cs new file mode 100644 index 0000000000..cd28783544 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/CommitAction.cs @@ -0,0 +1,8 @@ +namespace Elsa.Workflows.CommitStates; + +public enum CommitAction +{ + Default, + Commit, + Skip +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/CommitStrategyMetadata.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/CommitStrategyMetadata.cs new file mode 100644 index 0000000000..da7a3763ae --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/CommitStrategyMetadata.cs @@ -0,0 +1,8 @@ +namespace Elsa.Workflows.CommitStates; + +public class CommitStrategyMetadata +{ + public string Name { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public string Description { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/ObjectRegistration.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ObjectRegistration.cs new file mode 100644 index 0000000000..fb98ac5c4a --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/ObjectRegistration.cs @@ -0,0 +1,7 @@ +namespace Elsa.Workflows.CommitStates; + +public class ObjectRegistration +{ + public T Strategy { get; set; } = default!; + public TMeta Metadata { get; set; } = default!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowCommitStateStrategyContext.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowCommitStateStrategyContext.cs new file mode 100644 index 0000000000..d649e08f19 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowCommitStateStrategyContext.cs @@ -0,0 +1,3 @@ +namespace Elsa.Workflows.CommitStates; + +public record WorkflowCommitStateStrategyContext(WorkflowExecutionContext WorkflowExecutionContext, WorkflowLifetimeEvent LifetimeEvent); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowCommitStrategyRegistration.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowCommitStrategyRegistration.cs new file mode 100644 index 0000000000..6842a01141 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowCommitStrategyRegistration.cs @@ -0,0 +1,14 @@ +namespace Elsa.Workflows.CommitStates; + +public class WorkflowCommitStrategyRegistration : ObjectRegistration +{ + public WorkflowCommitStrategyRegistration() + { + } + + public WorkflowCommitStrategyRegistration(IWorkflowCommitStrategy strategy, CommitStrategyMetadata metadata) + { + Strategy = strategy; + Metadata = metadata; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowLifetimeEvent.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowLifetimeEvent.cs new file mode 100644 index 0000000000..1819083c1a --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowLifetimeEvent.cs @@ -0,0 +1,9 @@ +namespace Elsa.Workflows.CommitStates; + +public enum WorkflowLifetimeEvent +{ + WorkflowExecuting, + ActivityExecuting, + ActivityExecuted, + WorkflowExecuted +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowStrategyDescriptor.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowStrategyDescriptor.cs new file mode 100644 index 0000000000..1aa38e723d --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Models/WorkflowStrategyDescriptor.cs @@ -0,0 +1,3 @@ +namespace Elsa.Workflows.CommitStates; + +public record WorkflowStrategyDescriptor(string Name, string Description); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Options/CommitStateOptions.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Options/CommitStateOptions.cs new file mode 100644 index 0000000000..cf607f9862 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Options/CommitStateOptions.cs @@ -0,0 +1,7 @@ +namespace Elsa.Workflows.CommitStates; + +public class CommitStateOptions +{ + public IDictionary WorkflowCommitStrategies { get; set; } = new Dictionary(); + public IDictionary ActivityCommitStrategies { get; set; } = new Dictionary(); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Services/DefaultCommitStrategyRegistry.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Services/DefaultCommitStrategyRegistry.cs new file mode 100644 index 0000000000..ff72157d1e --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Services/DefaultCommitStrategyRegistry.cs @@ -0,0 +1,38 @@ +namespace Elsa.Workflows.CommitStates; + +public class DefaultCommitStrategyRegistry : ICommitStrategyRegistry +{ + private readonly IDictionary _workflowStrategies = new Dictionary(); + private readonly IDictionary _activityStrategies = new Dictionary(); + + + public IEnumerable ListWorkflowStrategyRegistrations() + { + return _workflowStrategies.Values; + } + + public IEnumerable ListActivityStrategyRegistrations() + { + return _activityStrategies.Values; + } + + public void RegisterStrategy(WorkflowCommitStrategyRegistration registration) + { + _workflowStrategies[registration.Metadata.Name] = registration; + } + + public void RegisterStrategy(ActivityCommitStrategyRegistration registration) + { + _activityStrategies[registration.Metadata.Name] = registration; + } + + public IWorkflowCommitStrategy? FindWorkflowStrategy(string name) + { + return _workflowStrategies.TryGetValue(name, out var registration) ? registration.Strategy : null; + } + + public IActivityCommitStrategy? FindActivityStrategy(string name) + { + return _activityStrategies.TryGetValue(name, out var registration) ? registration.Strategy : null; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/CommitAlwaysActivityStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/CommitAlwaysActivityStrategy.cs new file mode 100644 index 0000000000..c803a371a1 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/CommitAlwaysActivityStrategy.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Represents a strategy that always commits the workflow whenever the associated activity is about to execute or has executed. +/// This strategy ensures that the workflow's state is persisted at both pre-execution and post-execution stages of the activity. +/// +[DisplayName("Always Commit")] +[Description("Always commit the workflow state when the activity is about to execute or has executed.")] +public class CommitAlwaysActivityStrategy : IActivityCommitStrategy +{ + public CommitAction ShouldCommit(ActivityCommitStateStrategyContext context) + { + return CommitAction.Commit; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/CommitNeverActivityStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/CommitNeverActivityStrategy.cs new file mode 100644 index 0000000000..cdc42514a8 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/CommitNeverActivityStrategy.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// A strategy implementation of that specifies +/// the workflow should never commit when the associated activity is executed or has executed. +/// This overrides any workflow-level commit strategy. +/// +[DisplayName("Never Commit")] +[Description("Never commit the workflow state when the activity is about to execute or has executed.")] +public class CommitNeverActivityStrategy : IActivityCommitStrategy +{ + public CommitAction ShouldCommit(ActivityCommitStateStrategyContext context) + { + return CommitAction.Skip; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/DefaultActivityStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/DefaultActivityStrategy.cs new file mode 100644 index 0000000000..5eeaedc697 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/DefaultActivityStrategy.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Represents the default activity commit strategy for workflow activities. +/// +/// +/// This strategy determines whether a workflow should commit changes during the execution of an activity. +/// By default, it delegates the commit behavior to the workflow's global commit options or other overriding strategies. +/// +[DisplayName("Default")] +[Description("The default activity commit strategy.")] +public class DefaultActivityStrategy : IActivityCommitStrategy +{ + public CommitAction ShouldCommit(ActivityCommitStateStrategyContext context) + { + return CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/ExecutedActivityStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/ExecutedActivityStrategy.cs new file mode 100644 index 0000000000..d4c1ec976d --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/ExecutedActivityStrategy.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Represents an activity commit strategy that determines whether a commit action should occur +/// after an activity has executed within a workflow. +/// +/// +/// This strategy evaluates the activity's execution events, specifically committing only if the +/// activity's lifetime event is "ActivityExecuted". +/// +[DisplayName("Executed")] +[Description("Commit the workflow state after the activity has executed.")] +public class ExecutedActivityStrategy : IActivityCommitStrategy +{ + public CommitAction ShouldCommit(ActivityCommitStateStrategyContext context) + { + return context.LifetimeEvent == ActivityLifetimeEvent.ActivityExecuted ? CommitAction.Commit : CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/ExecutingActivityStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/ExecutingActivityStrategy.cs new file mode 100644 index 0000000000..a16ea900ae --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Activities/ExecutingActivityStrategy.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Represents a commit strategy that evaluates whether a workflow commit should occur +/// when an activity is in the "Executing" state. +/// +/// +/// This strategy determines commit behavior based on the activity's lifecycle event. +/// Specifically, it commits the workflow if the activity is currently executing +/// (i.e., the lifetime event is `ActivityLifetimeEvent.ActivityExecuting`). +/// For all other states, it defaults to no specific commit action. +/// +[DisplayName("Executing")] +[Description("Commit the workflow state when the activity is executing.")] +public class ExecutingActivityStrategy : IActivityCommitStrategy +{ + public CommitAction ShouldCommit(ActivityCommitStateStrategyContext context) + { + return context.LifetimeEvent == ActivityLifetimeEvent.ActivityExecuting ? CommitAction.Commit : CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/ActivityExecutedWorkflowStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/ActivityExecutedWorkflowStrategy.cs new file mode 100644 index 0000000000..d35187ff9c --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/ActivityExecutedWorkflowStrategy.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Represents a commit strategy that determines whether a workflow state should be committed +/// based on the "ActivityExecuted" lifetime event of an activity. +/// +/// +/// This strategy evaluates the provided during execution +/// and returns a action if the activity's lifetime event corresponds +/// to . Otherwise, it defaults to . +/// +[DisplayName("Activity Executed")] +[Description("Determines whether a workflow state should be committed if the current activity has executed.")] +public class ActivityExecutedWorkflowStrategy : IWorkflowCommitStrategy +{ + public CommitAction ShouldCommit(WorkflowCommitStateStrategyContext context) + { + return context.LifetimeEvent == WorkflowLifetimeEvent.ActivityExecuted ? CommitAction.Commit : CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/ActivityExecutingWorkflowStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/ActivityExecutingWorkflowStrategy.cs new file mode 100644 index 0000000000..5f891a5258 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/ActivityExecutingWorkflowStrategy.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Represents a workflow commit strategy that determines whether a commit should occur +/// during the ActivityExecuting lifecycle event of an activity. +/// +/// +/// This strategy evaluates the workflow execution context and checks if the current lifecycle event is +/// . If the condition is met, the strategy indicates +/// that a commit action should be performed. Otherwise, a default action will be returned. +/// +[DisplayName("Activity Executing")] +[Description("Determines whether a workflow state should be committed if the current activity is executing.")] +public class ActivityExecutingWorkflowStrategy : IWorkflowCommitStrategy +{ + public CommitAction ShouldCommit(WorkflowCommitStateStrategyContext context) + { + return context.LifetimeEvent == WorkflowLifetimeEvent.ActivityExecuting ? CommitAction.Commit : CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/DefaultWorkflowStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/DefaultWorkflowStrategy.cs new file mode 100644 index 0000000000..505f32788b --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/DefaultWorkflowStrategy.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Represents the default strategy for determining whether a workflow should commit its state. +/// +/// +/// This strategy always returns the default commit action as defined by the enum, +/// ensuring that workflows adhere to the standard behavior unless overridden by custom strategies. +/// +[DisplayName("Default")] +[Description("The default workflow commit strategy.")] +public class DefaultWorkflowStrategy : IWorkflowCommitStrategy +{ + public CommitAction ShouldCommit(WorkflowCommitStateStrategyContext context) + { + return CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/PeriodicWorkflowStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/PeriodicWorkflowStrategy.cs new file mode 100644 index 0000000000..62f481667f --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/PeriodicWorkflowStrategy.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using Elsa.Common; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Implements a periodic workflow commit strategy based on a specified time interval. +/// This strategy determines if a workflow should commit by comparing the elapsed time +/// since the last commit with the configured interval. +/// +[DisplayName("Periodic")] +[Description("Determines whether a workflow state should be committed based on a specified time interval.")] +public class PeriodicWorkflowStrategy(TimeSpan interval) : IWorkflowCommitStrategy +{ + private static readonly object LastCommitPropertyKey = new(); + public TimeSpan Interval { get; } = interval; + + public CommitAction ShouldCommit(WorkflowCommitStateStrategyContext context) + { + var lastCommit = context.WorkflowExecutionContext.TransientProperties.TryGetValue(LastCommitPropertyKey, out var value) ? (DateTimeOffset?)value : null; + var now = context.WorkflowExecutionContext.GetRequiredService().UtcNow; + var shouldCommit = lastCommit == null || (now - lastCommit.Value).TotalMilliseconds > Interval.TotalMilliseconds; + + if (shouldCommit) + context.WorkflowExecutionContext.TransientProperties[LastCommitPropertyKey] = now; + + return shouldCommit ? CommitAction.Commit : CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/WorkflowExecutedWorkflowStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/WorkflowExecutedWorkflowStrategy.cs new file mode 100644 index 0000000000..8be2a361af --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/WorkflowExecutedWorkflowStrategy.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Represents a workflow commit strategy that commits changes when the workflow has been executed. +/// +[DisplayName("Workflow Executed")] +[Description("Commit the workflow state when the workflow has been executed.")] +public class WorkflowExecutedWorkflowStrategy : IWorkflowCommitStrategy +{ + public CommitAction ShouldCommit(WorkflowCommitStateStrategyContext context) + { + return context.LifetimeEvent == WorkflowLifetimeEvent.WorkflowExecuted ? CommitAction.Commit : CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/WorkflowExecutingWorkflowStrategy.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/WorkflowExecutingWorkflowStrategy.cs new file mode 100644 index 0000000000..91162258e6 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Strategies/Workflows/WorkflowExecutingWorkflowStrategy.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; + +namespace Elsa.Workflows.CommitStates.Strategies; + +/// +/// Implements a commit strategy that determines whether the workflow state +/// should be committed when the workflow is in the "executing" lifetime event. +/// +/// +/// This strategy checks the current context's `LifetimeEvent` and commits +/// the workflow state if it corresponds to the `WorkflowExecuting` event. +/// Otherwise, it defaults to no explicit commit action. +/// +[DisplayName("Workflow Executing")] +[Description("Commit the workflow state when the workflow is executing.")] +public class WorkflowExecutingWorkflowStrategy : IWorkflowCommitStrategy +{ + public CommitAction ShouldCommit(WorkflowCommitStateStrategyContext context) + { + return context.LifetimeEvent == WorkflowLifetimeEvent.WorkflowExecuting ? CommitAction.Commit : CommitAction.Default; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Tasks/PopulateCommitStrategyRegistry.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Tasks/PopulateCommitStrategyRegistry.cs new file mode 100644 index 0000000000..2bd9b962de --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Tasks/PopulateCommitStrategyRegistry.cs @@ -0,0 +1,16 @@ +using Elsa.Common; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; + +namespace Elsa.Workflows.CommitStates.Tasks; + +[UsedImplicitly] +public class PopulateCommitStrategyRegistry(ICommitStrategyRegistry registry, IOptions options) : IStartupTask +{ + public Task ExecuteAsync(CancellationToken cancellationToken) + { + foreach (var strategy in options.Value.WorkflowCommitStrategies.Values) registry.RegisterStrategy(strategy); + foreach (var strategy in options.Value.ActivityCommitStrategies.Values) registry.RegisterStrategy(strategy); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index 099f71c405..667faa22a2 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -20,6 +20,8 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable { private readonly ISystemClock _systemClock; private readonly List _bookmarks = []; + private ActivityStatus _status; + private Exception? _exception; private long _executionCount; private ActivityExecutionContext? _parentActivityExecutionContext; @@ -30,7 +32,6 @@ public ActivityExecutionContext( string id, WorkflowExecutionContext workflowExecutionContext, ActivityExecutionContext? parentActivityExecutionContext, - ExpressionExecutionContext expressionExecutionContext, IActivity activity, ActivityDescriptor activityDescriptor, DateTimeOffset startedAt, @@ -39,9 +40,15 @@ public ActivityExecutionContext( CancellationToken cancellationToken) { _systemClock = systemClock; + Properties = new ChangeTrackingDictionary(Taint); + ActivityState = new ChangeTrackingDictionary(Taint); + ActivityInput = new ChangeTrackingDictionary(Taint); WorkflowExecutionContext = workflowExecutionContext; _parentActivityExecutionContext = parentActivityExecutionContext; - ExpressionExecutionContext = expressionExecutionContext; + var expressionExecutionContextProps = ExpressionExecutionContextExtensions.CreateActivityExecutionContextPropertiesFrom(workflowExecutionContext, workflowExecutionContext.Input); + expressionExecutionContextProps[ExpressionExecutionContextExtensions.ActivityKey] = activity; + ExpressionExecutionContext = new(workflowExecutionContext.ServiceProvider, new(), parentActivityExecutionContext?.ExpressionExecutionContext ?? workflowExecutionContext.ExpressionExecutionContext, expressionExecutionContextProps, Taint, CancellationToken); + ; Activity = activity; ActivityDescriptor = activityDescriptor; StartedAt = startedAt; @@ -134,7 +141,18 @@ public IEnumerable Variables /// /// The current status of the activity. /// - public ActivityStatus Status { get; private set; } + public ActivityStatus Status + { + get => _status; + private set + { + if (value == _status) + return; + + _status = value; + Taint(); + } + } /// /// Sets the current status of the activity. @@ -147,10 +165,18 @@ public void TransitionTo(ActivityStatus status) /// /// Gets or sets the exception that occurred during the activity execution, if any. /// - public Exception? Exception { get; set; } + public Exception? Exception + { + get => _exception; + set + { + _exception = value; + Taint(); + } + } /// - public IDictionary Properties { get; set; } = new Dictionary(); + public IDictionary Properties { get; private set; } /// /// A transient dictionary of values that can be associated with this activity execution context. @@ -168,7 +194,7 @@ public void TransitionTo(ActivityStatus status) /// /// As of tool version 3.0, all activity Ids are already unique, so there's no need to construct a hierarchical ID public string NodeId => ActivityNode.NodeId; - + public ISet Children { get; } = new HashSet(); /// @@ -189,18 +215,23 @@ public void TransitionTo(ActivityStatus status) /// /// A dictionary of inputs for the current activity. /// - public IDictionary ActivityInput { get; set; } = new Dictionary(); + public IDictionary ActivityInput { get; private set; } /// /// Journal data will be added to the workflow execution log for the "Executed" event. /// // ReSharper disable once CollectionNeverQueried.Global - public IDictionary JournalData { get; } = new Dictionary(); + public IDictionary JournalData { get; } = new Dictionary(); /// /// Stores the evaluated inputs, serialized, for the current activity for historical purposes. /// - public IDictionary ActivityState { get; set; } = new Dictionary(); + public IDictionary ActivityState { get; } + + /// + /// Indicates whether the state of the current activity execution context has been modified. + /// + public bool IsDirty { get; private set; } /// /// Schedules the specified activity to be executed. @@ -357,13 +388,21 @@ public void CreateBookmarks(IEnumerable payloads, ExecuteActivityDelegat /// Adds each bookmark to the list of bookmarks. /// /// The bookmarks to add. - public void AddBookmarks(IEnumerable bookmarks) => _bookmarks.AddRange(bookmarks); + public void AddBookmarks(IEnumerable bookmarks) + { + _bookmarks.AddRange(bookmarks); + Taint(); + } /// /// Adds a bookmark to the list of bookmarks. /// /// The bookmark to add. - public void AddBookmark(Bookmark bookmark) => _bookmarks.Add(bookmark); + public void AddBookmark(Bookmark bookmark) + { + _bookmarks.Add(bookmark); + Taint(); + } /// /// Creates a bookmark so that this activity can be resumed at a later time. @@ -467,7 +506,11 @@ public Bookmark CreateBookmark(CreateBookmarkArgs? options = null) /// /// Clear all bookmarks. /// - public void ClearBookmarks() => _bookmarks.Clear(); + public void ClearBookmarks() + { + _bookmarks.Clear(); + Taint(); + } /// /// Returns a property value associated with the current activity context. @@ -674,7 +717,22 @@ public void ClearCompletionCallbacks() WorkflowExecutionContext.RemoveCompletionCallbacks(entriesToRemove); } - internal void IncrementExecutionCount() => _executionCount++; + public void Taint() + { + if (!IsDirty) + IsDirty = true; + } + + public void ClearTaint() + { + if (IsDirty) + IsDirty = false; + } + + internal void IncrementExecutionCount() + { + _executionCount++; + } private MemoryBlock? GetMemoryBlock(MemoryBlockReference locationBlockReference) { diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs index 101685f746..345295d329 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs @@ -4,6 +4,7 @@ using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.Workflows.Activities; +using Elsa.Workflows.CommitStates; using Elsa.Workflows.Exceptions; using Elsa.Workflows.Helpers; using Elsa.Workflows.Memory; @@ -37,6 +38,7 @@ public partial class WorkflowExecutionContext : IExecutionContext private readonly IList _completionCallbackEntries = new List(); private IList _activityExecutionContexts; private readonly IHasher _hasher; + private readonly ICommitStateHandler _commitStateHandler; /// /// Initializes a new instance of . @@ -61,6 +63,7 @@ private WorkflowExecutionContext( ActivityRegistry = serviceProvider.GetRequiredService(); ActivityRegistryLookup = serviceProvider.GetRequiredService(); _hasher = serviceProvider.GetRequiredService(); + _commitStateHandler = serviceProvider.GetRequiredService(); SubStatus = WorkflowSubStatus.Pending; Id = id; CorrelationId = correlationId; @@ -510,7 +513,7 @@ public T UpdateProperty(string key, Func updater) internal void TransitionTo(WorkflowSubStatus subStatus) { if (!ValidateStatusTransition()) - throw new Exception($"Cannot transition from {SubStatus} to {subStatus}"); + throw new($"Cannot transition from {SubStatus} to {subStatus}"); SubStatus = subStatus; UpdatedAt = SystemClock.UtcNow; @@ -531,20 +534,15 @@ public async Task CreateActivityExecutionContextAsync( var activityDescriptor = await ActivityRegistryLookup.FindAsync(activity) ?? throw new ActivityNotFoundException(activity.Type); var tag = options?.Tag; var parentContext = options?.Owner; - var parentExpressionExecutionContext = parentContext?.ExpressionExecutionContext ?? ExpressionExecutionContext; - var properties = ExpressionExecutionContextExtensions.CreateActivityExecutionContextPropertiesFrom(this, Input); - properties[ExpressionExecutionContextExtensions.ActivityKey] = activity; - var memory = new MemoryRegister(); var now = SystemClock.UtcNow; - var expressionExecutionContext = new ExpressionExecutionContext(ServiceProvider, memory, parentExpressionExecutionContext, properties, CancellationToken); var id = IdentityGenerator.GenerateId(); - var activityExecutionContext = new ActivityExecutionContext(id, this, parentContext, expressionExecutionContext, activity, activityDescriptor, now, tag, SystemClock, CancellationToken); + var activityExecutionContext = new ActivityExecutionContext(id, this, parentContext, activity, activityDescriptor, now, tag, SystemClock, CancellationToken); var variablesToDeclare = options?.Variables ?? Array.Empty(); var variableContainer = new[] { activityExecutionContext.ActivityNode }.Concat(activityExecutionContext.ActivityNode.Ancestors()).FirstOrDefault(x => x.Activity is IVariableContainer)?.Activity as IVariableContainer; - expressionExecutionContext.TransientProperties[ExpressionExecutionContextExtensions.ActivityExecutionContextKey] = activityExecutionContext; + activityExecutionContext.ExpressionExecutionContext.TransientProperties[ExpressionExecutionContextExtensions.ActivityExecutionContextKey] = activityExecutionContext; if (variableContainer != null) { @@ -555,11 +553,12 @@ public async Task CreateActivityExecutionContextAsync( activityExecutionContext.DynamicVariables.Add(variable); // Assign the variable to the expression execution context. - expressionExecutionContext.CreateVariable(variable.Name, variable.Value); + activityExecutionContext.ExpressionExecutionContext.CreateVariable(variable.Name, variable.Value); } } - activityExecutionContext.ActivityInput = options?.Input ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + var activityInput = options?.Input ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + activityExecutionContext.ActivityInput.Merge(activityInput); return activityExecutionContext; } @@ -574,11 +573,36 @@ public async Task CreateActivityExecutionContextAsync( public void AddActivityExecutionContext(ActivityExecutionContext context) => _activityExecutionContexts.Add(context); /// Removes the specified from the workflow execution context. - public void RemoveActivityExecutionContext(ActivityExecutionContext context) => _activityExecutionContexts.Remove(context); + public void RemoveActivityExecutionContext(ActivityExecutionContext context) + { + _activityExecutionContexts.Remove(context); + context.ParentActivityExecutionContext?.Children.Remove(context); + } /// Removes the specified from the workflow execution context. /// The predicate used to filter the activity execution contexts to remove. - public void RemoveActivityExecutionContext(Func predicate) => _activityExecutionContexts.RemoveWhere(predicate); + public void RemoveActivityExecutionContexts(Func predicate) + { + var itemsToRemove = _activityExecutionContexts.Where(predicate).ToList(); + foreach (var item in itemsToRemove) + RemoveActivityExecutionContext(item); + } + + /// + /// Removes all completed activity execution contexts that have a parent activity execution context. + /// + public void ClearCompletedActivityExecutionContexts() + { + RemoveActivityExecutionContexts(x => x is { IsCompleted: true, ParentActivityExecutionContext: not null }); + } + + public IEnumerable GetActiveActivityExecutionContexts() + { + // Filter out completed activity execution contexts, except for the root Workflow activity context, which stores workflow-level variables. + // This will currently break scripts accessing activity output directly, but there's a workaround for that via variable capturing. + // We may ultimately restore direct output access, but in a different way. + return ActivityExecutionContexts.Where(x => !x.IsCompleted || x.ParentActivityExecutionContext == null); + } /// /// Records the output of the specified activity into the current workflow execution context. @@ -614,4 +638,9 @@ private bool ValidateStatusTransition() var currentMainStatus = GetMainStatus(SubStatus); return currentMainStatus != WorkflowStatus.Finished; } + + public Task CommitAsync() + { + return _commitStateHandler.CommitAsync(this, CancellationToken); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contracts/IExecutionContext.cs index 09ddfc5d94..41f6d9cfd0 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IExecutionContext.cs @@ -31,5 +31,5 @@ public interface IExecutionContext /// /// A dictionary of values that can be associated with this activity execution context. /// - public IDictionary Properties { get; set; } + public IDictionary Properties { get; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj.DotSettings b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj.DotSettings index f05e636df4..63e48fa9c1 100644 --- a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj.DotSettings +++ b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj.DotSettings @@ -7,6 +7,14 @@ True True True + True + True + True + True + True + True + True + True True True True diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs index 4d657080ad..ff9bee7c5e 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs @@ -11,6 +11,7 @@ public static class ActivityPropertyExtensions private static readonly string[] CanStartWorkflowPropertyName = ["canStartWorkflow", "CanStartWorkflow"]; private static readonly string[] RunAsynchronouslyPropertyName = ["runAsynchronously", "RunAsynchronously"]; private static readonly string[] SourcePropertyName = ["source", "Source"]; + private static readonly string[] CommitStrategyName = ["commitStrategyName", "CommitStrategyName"]; /// /// Gets a flag indicating whether this activity can be used for starting a workflow. @@ -47,6 +48,23 @@ public static class ActivityPropertyExtensions /// public static void SetSource(this IActivity activity, string value) => activity.CustomProperties[SourcePropertyName[0]] = value; + /// + /// Gets the commit state behavior for the specified activity. + /// + public static string? GetCommitStrategy(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CommitStrategyName, () => null); + + /// + /// Sets the commit state behavior for the specified activity. + /// + public static TActivity SetCommitStrategy(this TActivity activity, string? name) where TActivity : IActivity + { + if (string.IsNullOrWhiteSpace(name)) + activity.CustomProperties.Remove(CommitStrategyName[0]); + else + activity.CustomProperties[CommitStrategyName[0]] = name; + return activity; + } + /// /// Sets the source file and line number where this activity was instantiated, if any. /// @@ -58,22 +76,22 @@ public static void SetSource(this IActivity activity, string? sourceFile, int? l var source = $"{Path.GetFileName(sourceFile)}:{lineNumber}"; activity.SetSource(source); } - + /// /// Gets the display text for the specified activity. /// - public static string? GetDisplayText(this IActivity activity) => activity.Metadata.TryGetValue("displayText", out var value) ? value.ToString() : default; - + public static string? GetDisplayText(this IActivity activity) => activity.Metadata.TryGetValue("displayText", out var value) ? value.ToString() : null; + /// /// Sets the display text for the specified activity. /// public static void SetDisplayText(this IActivity activity, string value) => activity.Metadata["displayText"] = value; - + /// /// Gets the description for the specified activity. /// - public static string? GetDescription(this IActivity activity) => activity.Metadata.TryGetValue("description", out var value) ? value.ToString() : default; - + public static string? GetDescription(this IActivity activity) => activity.Metadata.TryGetValue("description", out var value) ? value.ToString() : null; + /// /// Sets the description for the specified activity. /// diff --git a/src/modules/Elsa.Workflows.Core/Extensions/TriggerExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/TriggerExtensions.cs index a6de0a24fe..87a051e0fc 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/TriggerExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/TriggerExtensions.cs @@ -50,7 +50,7 @@ public static async Task CreateExpressionExecutionCo var expressionInput = new Dictionary(); var applicationProperties = ExpressionExecutionContextExtensions.CreateTriggerIndexingPropertiesFrom(context.Workflow, expressionInput); applicationProperties[ExpressionExecutionContextExtensions.ActivityKey] = trigger; - var expressionExecutionContext = new ExpressionExecutionContext(serviceProvider, register, default, applicationProperties, cancellationToken); + var expressionExecutionContext = new ExpressionExecutionContext(serviceProvider, register, null, applicationProperties, null, cancellationToken); // Evaluate activity inputs before requesting trigger data. foreach (var namedInput in assignedInputs) diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 67e5cb4a67..1212e8bff9 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -9,6 +9,7 @@ using Elsa.Features.Services; using Elsa.Workflows.ActivationValidators; using Elsa.Workflows.Builders; +using Elsa.Workflows.CommitStates; using Elsa.Workflows.IncidentStrategies; using Elsa.Workflows.LogPersistence; using Elsa.Workflows.LogPersistence.Strategies; @@ -36,6 +37,7 @@ namespace Elsa.Workflows.Features; [DependsOn(typeof(MediatorFeature))] [DependsOn(typeof(DefaultFormattersFeature))] [DependsOn(typeof(MultitenancyFeature))] +[DependsOn(typeof(CommitStrategiesFeature))] public class WorkflowsFeature : FeatureBase { /// diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs index 65652ea04a..9b5ef9cd9d 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs @@ -1,5 +1,6 @@ using Elsa.Extensions; using Elsa.Workflows.Activities; +using Elsa.Workflows.CommitStates; using Elsa.Workflows.Pipelines.ActivityExecution; using Microsoft.Extensions.Logging; @@ -19,19 +20,19 @@ public static class ActivityInvokerMiddlewareExtensions /// /// A default activity execution middleware component that evaluates the current activity's properties, executes the activity and adds any produced bookmarks to the workflow execution context. /// -public class DefaultActivityInvokerMiddleware(ActivityMiddlewareDelegate next, ILogger logger) +public class DefaultActivityInvokerMiddleware(ActivityMiddlewareDelegate next, ICommitStrategyRegistry commitStrategyRegistry, ILogger logger) : IActivityExecutionMiddleware { /// public async ValueTask InvokeAsync(ActivityExecutionContext context) { context.CancellationToken.ThrowIfCancellationRequested(); - + var workflowExecutionContext = context.WorkflowExecutionContext; // Evaluate input properties. await EvaluateInputPropertiesAsync(context); - + // Prevent the activity from being started if cancellation is requested. if (context.CancellationToken.IsCancellationRequested) { @@ -39,7 +40,7 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) context.AddExecutionLogEntry("Activity cancelled"); return; } - + // Check if the activity can be executed. if (!await context.Activity.CanExecuteAsync(context)) { @@ -48,6 +49,10 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) return; } + // Conditionally commit the workflow state. + if (ShouldCommit(context, ActivityLifetimeEvent.ActivityExecuting)) + await context.WorkflowExecutionContext.CommitAsync(); + context.TransitionTo(ActivityStatus.Running); // Execute activity. @@ -78,6 +83,10 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) workflowExecutionContext.Bookmarks.AddRange(context.Bookmarks); logger.LogDebug("Added {BookmarkCount} bookmarks to the workflow execution context", context.Bookmarks.Count); } + + // Conditionally commit the workflow state. + if (ShouldCommit(context, ActivityLifetimeEvent.ActivityExecuted)) + await context.WorkflowExecutionContext.CommitAsync(); } /// @@ -111,4 +120,41 @@ private async Task EvaluateInputPropertiesAsync(ActivityExecutionContext context // Evaluate input properties. await context.EvaluateInputPropertiesAsync(); } + + private bool ShouldCommit(ActivityExecutionContext context, ActivityLifetimeEvent lifetimeEvent) + { + var strategyName = context.Activity.GetCommitStrategy(); + var strategy = string.IsNullOrWhiteSpace(strategyName) ? null : commitStrategyRegistry.FindActivityStrategy(strategyName); + var commitAction = CommitAction.Default; + + if (strategy != null) + { + var strategyContext = new ActivityCommitStateStrategyContext(context, lifetimeEvent); + commitAction = strategy.ShouldCommit(strategyContext); + } + + switch (commitAction) + { + case CommitAction.Skip: + return false; + case CommitAction.Commit: + return true; + case CommitAction.Default: + { + var workflowStrategyName = context.WorkflowExecutionContext.Workflow.Options.CommitStrategyName; + var workflowStrategy = string.IsNullOrWhiteSpace(workflowStrategyName) ? null : commitStrategyRegistry.FindWorkflowStrategy(workflowStrategyName); + + if(workflowStrategy == null) + return false; + + var workflowLifetimeEvent = lifetimeEvent == ActivityLifetimeEvent.ActivityExecuting ? WorkflowLifetimeEvent.ActivityExecuting : WorkflowLifetimeEvent.ActivityExecuted; + var workflowCommitStateStrategyContext = new WorkflowCommitStateStrategyContext(context.WorkflowExecutionContext, workflowLifetimeEvent); + commitAction = workflowStrategy.ShouldCommit(workflowCommitStateStrategyContext); + + return commitAction == CommitAction.Commit; + } + default: + throw new ArgumentOutOfRangeException(nameof(commitAction), commitAction, "Unknown commit action"); + } + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs index f9d1645d90..200f8c2035 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs @@ -1,4 +1,5 @@ using Elsa.Extensions; +using Elsa.Workflows.CommitStates; using Elsa.Workflows.Models; using Elsa.Workflows.Options; using Elsa.Workflows.Pipelines.WorkflowExecution; @@ -19,16 +20,8 @@ public static class UseActivitySchedulerMiddlewareExtensions /// /// A workflow execution middleware component that executes scheduled work items. /// -public class DefaultActivitySchedulerMiddleware : WorkflowExecutionMiddleware +public class DefaultActivitySchedulerMiddleware(WorkflowMiddlewareDelegate next, IActivityInvoker activityInvoker, ICommitStrategyRegistry commitStrategyRegistry) : WorkflowExecutionMiddleware(next) { - private readonly IActivityInvoker _activityInvoker; - - /// - public DefaultActivitySchedulerMiddleware(WorkflowMiddlewareDelegate next, IActivityInvoker activityInvoker) : base(next) - { - _activityInvoker = activityInvoker; - } - /// public override async ValueTask InvokeAsync(WorkflowExecutionContext context) { @@ -36,6 +29,8 @@ public override async ValueTask InvokeAsync(WorkflowExecutionContext context) context.TransitionTo(WorkflowSubStatus.Executing); + await ConditionallyCommitStateAsync(context, WorkflowLifetimeEvent.WorkflowExecuting); + while (scheduler.HasAny) { // Do not start a workflow if cancellation has been requested. @@ -63,6 +58,21 @@ private async Task ExecuteWorkItemAsync(WorkflowExecutionContext context, Activi Input = workItem.Input }; - await _activityInvoker.InvokeAsync(context, workItem.Activity, options); + await activityInvoker.InvokeAsync(context, workItem.Activity, options); + } + + private async Task ConditionallyCommitStateAsync(WorkflowExecutionContext context, WorkflowLifetimeEvent lifetimeEvent) + { + var strategyName = context.Workflow.Options.CommitStrategyName; + var strategy = string.IsNullOrWhiteSpace(strategyName) ? null : commitStrategyRegistry.FindWorkflowStrategy(strategyName); + + if(strategy == null) + return; + + var strategyContext = new WorkflowCommitStateStrategyContext(context, lifetimeEvent); + var commitAction = strategy.ShouldCommit(strategyContext); + + if (commitAction is CommitAction.Commit) + await context.CommitAsync(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/ChangeTrackingDictionary.cs b/src/modules/Elsa.Workflows.Core/Models/ChangeTrackingDictionary.cs new file mode 100644 index 0000000000..e4a18a1898 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Models/ChangeTrackingDictionary.cs @@ -0,0 +1,27 @@ +namespace Elsa.Workflows; + +public class ChangeTrackingDictionary(Action onChange) : Dictionary where TKey : notnull +{ + public new void Add(TKey key, TValue value) + { + base.Add(key, value); + onChange(); + } + + public new bool Remove(TKey key) + { + var result = base.Remove(key); + if (result) onChange(); + return result; + } + + public new TValue this[TKey key] + { + get => base[key]; + set + { + base[key] = value; + onChange(); + } + } +} diff --git a/src/modules/Elsa.Workflows.Core/Models/WorkflowOptions.cs b/src/modules/Elsa.Workflows.Core/Models/WorkflowOptions.cs index e4b82b1162..4b688f914f 100644 --- a/src/modules/Elsa.Workflows.Core/Models/WorkflowOptions.cs +++ b/src/modules/Elsa.Workflows.Core/Models/WorkflowOptions.cs @@ -29,4 +29,9 @@ public class WorkflowOptions /// The type of to use when a fault occurs in the workflow. /// public Type? IncidentStrategyType { get; set; } + + /// + /// The options for committing workflow state. + /// + public string? CommitStrategyName { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonPayloadSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonPayloadSerializer.cs index 26b811d892..c5ab81a8f9 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonPayloadSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonPayloadSerializer.cs @@ -74,7 +74,7 @@ public JsonSerializerOptions GetOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; options.Converters.Add(new JsonStringEnumConverter()); diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityInvoker.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityInvoker.cs index 13f074bfa2..a7d86aa5d6 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityInvoker.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityInvoker.cs @@ -26,6 +26,7 @@ public async Task InvokeAsync(WorkflowExecutionContext workflowExecutionContext, { // Create a new activity execution context. activityExecutionContext = await workflowExecutionContext.CreateActivityExecutionContextAsync(activity, options); + activityExecutionContext.Taint(); // Add the activity context to the workflow context. workflowExecutionContext.AddActivityExecutionContext(activityExecutionContext); diff --git a/src/modules/Elsa.Workflows.Core/Services/NoopCommitStateHandler.cs b/src/modules/Elsa.Workflows.Core/Services/NoopCommitStateHandler.cs index 8503bddb7c..b6b55d6bde 100644 --- a/src/modules/Elsa.Workflows.Core/Services/NoopCommitStateHandler.cs +++ b/src/modules/Elsa.Workflows.Core/Services/NoopCommitStateHandler.cs @@ -1,9 +1,15 @@ +using Elsa.Workflows.CommitStates; using Elsa.Workflows.State; namespace Elsa.Workflows; public class NoopCommitStateHandler : ICommitStateHandler { + public Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + public Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, WorkflowState workflowState, CancellationToken cancellationToken = default) { return Task.CompletedTask; diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs index 7962a96b7c..a798427993 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs @@ -1,6 +1,7 @@ using Elsa.Extensions; using Elsa.Mediator.Contracts; using Elsa.Workflows.Activities; +using Elsa.Workflows.CommitStates; using Elsa.Workflows.Models; using Elsa.Workflows.Notifications; using Elsa.Workflows.Options; diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs index 2767dc0991..99a11a2141 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs @@ -139,8 +139,11 @@ private static async Task ApplyActivityExecutionContextsAsync(WorkflowState stat var properties = activityExecutionContextState.Properties; var activityExecutionContext = await workflowExecutionContext.CreateActivityExecutionContextAsync(activity); activityExecutionContext.Id = activityExecutionContextState.Id; - activityExecutionContext.Properties = properties; - activityExecutionContext.ActivityState = activityExecutionContextState.ActivityState ?? new Dictionary(); + activityExecutionContext.Properties.Merge(properties); + + if(activityExecutionContextState.ActivityState != null) + activityExecutionContext.ActivityState.Merge(activityExecutionContextState.ActivityState); + activityExecutionContext.TransitionTo(activityExecutionContextState.Status); activityExecutionContext.StartedAt = activityExecutionContextState.StartedAt; activityExecutionContext.CompletedAt = activityExecutionContextState.CompletedAt; @@ -189,14 +192,14 @@ private void ApplyScheduledActivities(WorkflowState state, WorkflowExecutionCont private static void ExtractCompletionCallbacks(WorkflowState state, WorkflowExecutionContext workflowExecutionContext) { - // Assert all referenced owner contexts exist. - var activeContexts = GetActiveActivityExecutionContexts(workflowExecutionContext.ActivityExecutionContexts).ToList(); + // Assert that all referenced owner contexts exist. + var activeContexts = workflowExecutionContext.GetActiveActivityExecutionContexts().ToList(); foreach (var completionCallback in workflowExecutionContext.CompletionCallbacks) { var ownerContext = activeContexts.FirstOrDefault(x => x == completionCallback.Owner); if (ownerContext == null) - throw new Exception("Lost an owner context"); + throw new("Lost an owner context"); } var completionCallbacks = workflowExecutionContext @@ -217,7 +220,7 @@ ActivityExecutionContextState CreateActivityExecutionContextState(ActivityExecut var parentContext = activityExecutionContext.WorkflowExecutionContext.ActivityExecutionContexts.FirstOrDefault(x => x.Id == parentId); if (parentContext == null) - throw new Exception("We lost a context. This could indicate a bug in a parent activity that completed before (some of) its child activities."); + throw new("We lost a context. This could indicate a bug in a parent activity that completed before (some of) its child activities."); } var activityExecutionContextState = new ActivityExecutionContextState @@ -238,7 +241,7 @@ ActivityExecutionContextState CreateActivityExecutionContextState(ActivityExecut } // Only persist non-completed contexts. - state.ActivityExecutionContexts = GetActiveActivityExecutionContexts(workflowExecutionContext.ActivityExecutionContexts).Reverse().Select(CreateActivityExecutionContextState).ToList(); + state.ActivityExecutionContexts = workflowExecutionContext.GetActiveActivityExecutionContexts().Reverse().Select(CreateActivityExecutionContextState).ToList(); } private void ExtractScheduledActivities(WorkflowState state, WorkflowExecutionContext workflowExecutionContext) @@ -257,12 +260,4 @@ private void ExtractScheduledActivities(WorkflowState state, WorkflowExecutionCo state.ScheduledActivities = scheduledActivities.ToList(); } - - private static IEnumerable GetActiveActivityExecutionContexts(IEnumerable activityExecutionContexts) - { - // Filter out completed activity execution contexts, except for the root Workflow activity context, which stores workflow-level variables. - // This will currently break scripts accessing activity output directly, but there's a workaround for that via variable capturing. - // We may ultimately restore direct output access, but in a different way. - return activityExecutionContexts.Where(x => !x.IsCompleted || x.ParentActivityExecutionContext == null).ToList(); - } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowExecutionPipelineBuilderExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowExecutionPipelineBuilderExtensions.cs index cd48e66f64..9affb20587 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowExecutionPipelineBuilderExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowExecutionPipelineBuilderExtensions.cs @@ -18,9 +18,6 @@ public static IWorkflowExecutionPipelineBuilder UseDefaultPipeline(this IWorkflo pipelineBuilder .Reset() .UseEngineExceptionHandling() - .UseBookmarkPersistence() - .UseActivityExecutionLogPersistence() - .UseWorkflowExecutionLogPersistence() .UsePersistentVariables() .UseExceptionHandling() .UseDefaultActivityScheduler(); @@ -33,15 +30,18 @@ public static IWorkflowExecutionPipelineBuilder UseDefaultPipeline(this IWorkflo /// /// Installs middleware that persists bookmarks after workflow execution. /// + [Obsolete("This middleware is no longer used and will be removed in a future version. Bookmarks are now persisted through the commit state handler.")] public static IWorkflowExecutionPipelineBuilder UseBookmarkPersistence(this IWorkflowExecutionPipelineBuilder pipelineBuilder) => pipelineBuilder.UseMiddleware(); /// /// Installs middleware that persists the workflow execution journal. /// + [Obsolete("This middleware is no longer used and will be removed in a future version. Execution logs are now persisted through the commit state handler.")] public static IWorkflowExecutionPipelineBuilder UseWorkflowExecutionLogPersistence(this IWorkflowExecutionPipelineBuilder pipelineBuilder) => pipelineBuilder.UseMiddleware(); /// /// Installs middleware that persists activity execution records. /// + [Obsolete("This middleware is no longer used and will be removed in a future version. Activity state is now persisted through the commit state handler.")] public static IWorkflowExecutionPipelineBuilder UseActivityExecutionLogPersistence(this IWorkflowExecutionPipelineBuilder pipelineBuilder) => pipelineBuilder.UseMiddleware(); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 4ef1096150..5ee379d742 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -193,7 +193,7 @@ public override void Configure() Module.AddActivitiesFrom(); Module.Configure(workflows => { - workflows.CommitStateHandler = sp => sp.GetRequiredService(); + workflows.CommitStateHandler = sp => sp.GetRequiredService(); }); Services.Configure(options => @@ -263,11 +263,10 @@ public override void Apply() .AddScoped() .AddScoped() .AddScoped() - .AddScoped, ActivityExecutionRecordExtractor>() .AddScoped, WorkflowExecutionLogRecordExtractor>() .AddScoped() - .AddScoped() + .AddScoped() // Deprecated services. .AddScoped() diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs index e0ed60f80d..640a07744c 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Elsa.Extensions; +using Elsa.Workflows.CommitStates; using Elsa.Workflows.Middleware.Activities; using Elsa.Workflows.Models; using Elsa.Workflows.Options; @@ -18,8 +19,9 @@ public class BackgroundActivityInvokerMiddleware( ActivityMiddlewareDelegate next, ILogger logger, IIdentityGenerator identityGenerator, - IBackgroundActivityScheduler backgroundActivityScheduler) - : DefaultActivityInvokerMiddleware(next, logger) + IBackgroundActivityScheduler backgroundActivityScheduler, + ICommitStrategyRegistry commitStrategyRegistry) + : DefaultActivityInvokerMiddleware(next, commitStrategyRegistry, logger) { internal static string GetBackgroundActivityOutputKey(string activityNodeId) => $"__BackgroundActivityOutput:{activityNodeId}"; internal static string GetBackgroundActivityOutcomesKey(string activityNodeId) => $"__BackgroundActivityOutcomes:{activityNodeId}"; diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistActivityExecutionLogMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistActivityExecutionLogMiddleware.cs index e81f53fd7c..b659c86359 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistActivityExecutionLogMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistActivityExecutionLogMiddleware.cs @@ -1,17 +1,19 @@ using Elsa.Workflows.Pipelines.WorkflowExecution; -using Elsa.Workflows.Runtime.Entities; namespace Elsa.Workflows.Runtime.Middleware.Workflows; /// /// Creates and updates activity execution records from activity execution contexts. /// -public class PersistActivityExecutionLogMiddleware(WorkflowMiddlewareDelegate next, ILogRecordSink sink) : WorkflowExecutionMiddleware(next) +[Obsolete("This middleware is no longer used and will be removed in a future version. Activity state is now persisted through the commit state handler")] +public class PersistActivityExecutionLogMiddleware(WorkflowMiddlewareDelegate next) : WorkflowExecutionMiddleware(next) { /// public override async ValueTask InvokeAsync(WorkflowExecutionContext context) { await Next(context); - await sink.PersistExecutionLogsAsync(context); + + // Not used anymore. + //await sink.PersistExecutionLogsAsync(context); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistBookmarkMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistBookmarkMiddleware.cs index 0319baf070..c44b255ab9 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistBookmarkMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistBookmarkMiddleware.cs @@ -1,26 +1,20 @@ using Elsa.Workflows.Pipelines.WorkflowExecution; -using Elsa.Workflows.Runtime.Requests; namespace Elsa.Workflows.Runtime.Middleware.Workflows; /// /// Takes care of loading and persisting bookmarks. /// -public class PersistBookmarkMiddleware : WorkflowExecutionMiddleware +[Obsolete("This middleware is no longer used and will be removed in a future version. Bookmarks are now persisted through the commit state handler")] +public class PersistBookmarkMiddleware(WorkflowMiddlewareDelegate next) : WorkflowExecutionMiddleware(next) { - private readonly IBookmarksPersister _bookmarksPersister; - - /// - public PersistBookmarkMiddleware(WorkflowMiddlewareDelegate next, IBookmarksPersister bookmarksPersister) : base(next) - { - _bookmarksPersister = bookmarksPersister; - } - /// public override async ValueTask InvokeAsync(WorkflowExecutionContext context) { await Next(context); - var bookmarkRequest = new UpdateBookmarksRequest(context, context.BookmarksDiff, context.CorrelationId); - await _bookmarksPersister.PersistBookmarksAsync(bookmarkRequest); + + // Not used anymore. + // var bookmarkRequest = new UpdateBookmarksRequest(context, context.BookmarksDiff, context.CorrelationId); + // await _bookmarksPersister.PersistBookmarksAsync(bookmarkRequest); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistWorkflowExecutionLogMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistWorkflowExecutionLogMiddleware.cs index 25d863054c..4c2d395348 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistWorkflowExecutionLogMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistWorkflowExecutionLogMiddleware.cs @@ -1,12 +1,12 @@ using Elsa.Workflows.Pipelines.WorkflowExecution; -using Elsa.Workflows.Runtime.Entities; namespace Elsa.Workflows.Runtime.Middleware.Workflows; /// /// Takes care of persisting workflow execution log entries. /// -public class PersistWorkflowExecutionLogMiddleware(WorkflowMiddlewareDelegate next, ILogRecordSink sink) : WorkflowExecutionMiddleware(next) +[Obsolete("This middleware is no longer used and will be removed in a future version. Execution logs are now persisted through the commit state handler.")] +public class PersistWorkflowExecutionLogMiddleware(WorkflowMiddlewareDelegate next) : WorkflowExecutionMiddleware(next) { /// public override async ValueTask InvokeAsync(WorkflowExecutionContext context) @@ -14,6 +14,7 @@ public override async ValueTask InvokeAsync(WorkflowExecutionContext context) // Invoke next middleware. await Next(context); - await sink.PersistExecutionLogsAsync(context); + // Not used anymore. + //await sink.PersistExecutionLogsAsync(context); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistentVariablesMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistentVariablesMiddleware.cs index b743983da7..ab464717de 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistentVariablesMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistentVariablesMiddleware.cs @@ -5,28 +5,17 @@ namespace Elsa.Workflows.Runtime.Middleware.Workflows; /// /// Takes care of loading and persisting workflow variables. /// -public class PersistentVariablesMiddleware : WorkflowExecutionMiddleware +public class PersistentVariablesMiddleware(WorkflowMiddlewareDelegate next, IVariablePersistenceManager variablePersistenceManager) : WorkflowExecutionMiddleware(next) { - private readonly IVariablePersistenceManager _variablePersistenceManager; - - /// - /// Constructor. - /// - public PersistentVariablesMiddleware(WorkflowMiddlewareDelegate next, IVariablePersistenceManager variablePersistenceManager) : base(next) - { - _variablePersistenceManager = variablePersistenceManager; - } - /// public override async ValueTask InvokeAsync(WorkflowExecutionContext context) { // Load variables into the workflow execution context. - await _variablePersistenceManager.LoadVariablesAsync(context); + await variablePersistenceManager.LoadVariablesAsync(context); // Invoke next middleware. await Next(context); - // Persist variables. - await _variablePersistenceManager.SaveVariablesAsync(context); + // Variables are persisted through the commit state handler. } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/ActivityExecutionLogUpdated.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/ActivityExecutionLogUpdated.cs index f662d238de..9766cf8841 100644 --- a/src/modules/Elsa.Workflows.Runtime/Notifications/ActivityExecutionLogUpdated.cs +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/ActivityExecutionLogUpdated.cs @@ -8,4 +8,4 @@ namespace Elsa.Workflows.Runtime.Notifications; /// /// The workflow execution context. /// The activity execution records. -public record ActivityExecutionLogUpdated(WorkflowExecutionContext WorkflowExecutionContext, List Records) : INotification; \ No newline at end of file +public record ActivityExecutionLogUpdated(WorkflowExecutionContext WorkflowExecutionContext, ICollection Records) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionRecordExtractor.cs b/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionRecordExtractor.cs deleted file mode 100644 index 2936012500..0000000000 --- a/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionRecordExtractor.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Elsa.Workflows.Runtime.Entities; - -namespace Elsa.Workflows.Runtime; - -/// -/// Extracts activity execution log records. -/// -public class ActivityExecutionRecordExtractor(IActivityExecutionMapper activityExecutionMapper) : ILogRecordExtractor -{ - /// - public async Task> ExtractLogRecordsAsync(WorkflowExecutionContext context) - { - var activityExecutionContexts = context.ActivityExecutionContexts; - var tasks = activityExecutionContexts.Select(activityExecutionMapper.MapAsync).ToList(); - return await Task.WhenAll(tasks); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkUpdater.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkUpdater.cs index d42bfab610..d7a9be23e4 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkUpdater.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkUpdater.cs @@ -11,12 +11,15 @@ public class BookmarkUpdater(IBookmarkManager bookmarkManager, IBookmarkStore bo public async Task UpdateBookmarksAsync(UpdateBookmarksRequest request, CancellationToken cancellationToken = default) { var instanceId = request.WorkflowExecutionContext.Id; - await RemoveBookmarksAsync(instanceId, request.Diff.Removed, cancellationToken); - await StoreBookmarksAsync(request.WorkflowExecutionContext, request.Diff.Added, cancellationToken); + await RemoveBookmarksAsync(instanceId, request.Diff.Removed.ToList(), cancellationToken); + await StoreBookmarksAsync(request.WorkflowExecutionContext, request.Diff.Added.ToList(), cancellationToken); } - - private async Task RemoveBookmarksAsync(string workflowInstanceId, IEnumerable bookmarks, CancellationToken cancellationToken) + + private async Task RemoveBookmarksAsync(string workflowInstanceId, ICollection bookmarks, CancellationToken cancellationToken) { + if (bookmarks.Count == 0) + return; + var matchingIds = bookmarks.Select(x => x.Id).ToList(); var filter = new BookmarkFilter { @@ -25,9 +28,12 @@ private async Task RemoveBookmarksAsync(string workflowInstanceId, IEnumerable bookmarks, CancellationToken cancellationToken) + + private async Task StoreBookmarksAsync(WorkflowExecutionContext context, ICollection bookmarks, CancellationToken cancellationToken) { + if (bookmarks.Count == 0) + return; + foreach (var bookmark in bookmarks) { var storedBookmark = context.MapBookmark(bookmark); diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs new file mode 100644 index 0000000000..3df97e11c7 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs @@ -0,0 +1,34 @@ +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.Management; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Requests; +using Elsa.Workflows.State; + +namespace Elsa.Workflows.Runtime; + +public class DefaultCommitStateHandler( + IWorkflowInstanceManager workflowInstanceManager, + IBookmarksPersister bookmarkPersister, + IVariablePersistenceManager variablePersistenceManager, + ILogRecordSink activityExecutionLogRecordSink, + ILogRecordSink workflowExecutionLogRecordSink) : ICommitStateHandler +{ + public async Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, CancellationToken cancellationToken = default) + { + var workflowState = workflowInstanceManager.ExtractWorkflowState(workflowExecutionContext); + await CommitAsync(workflowExecutionContext, workflowState, cancellationToken); + } + + public async Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, WorkflowState workflowState, CancellationToken cancellationToken = default) + { + var updateBookmarksRequest = new UpdateBookmarksRequest(workflowExecutionContext, workflowExecutionContext.BookmarksDiff, workflowExecutionContext.CorrelationId); + await bookmarkPersister.PersistBookmarksAsync(updateBookmarksRequest); + await activityExecutionLogRecordSink.PersistExecutionLogsAsync(workflowExecutionContext, cancellationToken); + await workflowExecutionLogRecordSink.PersistExecutionLogsAsync(workflowExecutionContext, cancellationToken); + await variablePersistenceManager.SaveVariablesAsync(workflowExecutionContext); + await workflowInstanceManager.SaveAsync(workflowState, cancellationToken); + workflowExecutionContext.ExecutionLog.Clear(); + workflowExecutionContext.ClearCompletedActivityExecutionContexts(); + await workflowExecutionContext.ExecuteDeferredTasksAsync(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs b/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs index 2c280a22c8..a1fda06ea3 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs @@ -1,21 +1,35 @@ using Elsa.Mediator.Contracts; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Notifications; -using Open.Linq.AsyncExtensions; namespace Elsa.Workflows.Runtime.Services; /// /// This implementation saves directly through the store. /// -public class StoreActivityExecutionLogSink(IActivityExecutionStore activityExecutionStore, ILogRecordExtractor extractor, INotificationSender notificationSender) +public class StoreActivityExecutionLogSink( + IActivityExecutionStore activityExecutionStore, + IActivityExecutionMapper mapper, + INotificationSender notificationSender) : ILogRecordSink { /// public async Task PersistExecutionLogsAsync(WorkflowExecutionContext context, CancellationToken cancellationToken = default) { - var records = await extractor.ExtractLogRecordsAsync(context).ToList(); + // Select tainted activity execution contexts to avoid saving untainted ones multiple times. + var activityExecutionContexts = context.ActivityExecutionContexts.Where(x => x.IsDirty).ToList(); + + if (activityExecutionContexts.Count == 0) + return; + + var tasks = activityExecutionContexts.Select(mapper.MapAsync).ToList(); + var records = await Task.WhenAll(tasks); await activityExecutionStore.SaveManyAsync(records, cancellationToken); + + // Untaint activity execution contexts. + foreach (var activityExecutionContext in activityExecutionContexts) + activityExecutionContext.ClearTaint(); + await notificationSender.SendAsync(new ActivityExecutionLogUpdated(context, records), cancellationToken); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StoreCommitStateHandler.cs b/src/modules/Elsa.Workflows.Runtime/Services/StoreCommitStateHandler.cs deleted file mode 100644 index 8f3d334661..0000000000 --- a/src/modules/Elsa.Workflows.Runtime/Services/StoreCommitStateHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Elsa.Workflows.Management; -using Elsa.Workflows.State; - -namespace Elsa.Workflows.Runtime; - -public class StoreCommitStateHandler(IWorkflowInstanceManager workflowInstanceManager) : ICommitStateHandler -{ - public async Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, WorkflowState workflowState, CancellationToken cancellationToken = default) - { - await workflowInstanceManager.SaveAsync(workflowState, cancellationToken); - await workflowExecutionContext.ExecuteDeferredTasksAsync(); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StoreWorkflowExecutionLogSink.cs b/src/modules/Elsa.Workflows.Runtime/Services/StoreWorkflowExecutionLogSink.cs index a916678584..72bce2ebb1 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StoreWorkflowExecutionLogSink.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StoreWorkflowExecutionLogSink.cs @@ -14,6 +14,10 @@ public class StoreWorkflowExecutionLogSink(IWorkflowExecutionLogStore store, ILo public async Task PersistExecutionLogsAsync(WorkflowExecutionContext context, CancellationToken cancellationToken) { var records = await extractor.ExtractLogRecordsAsync(context).ToList(); + + if(records.Count == 0) + return; + await store.AddManyAsync(records, context.CancellationToken); await notificationSender.SendAsync(new WorkflowExecutionLogUpdated(context), context.CancellationToken); }