diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/CommitStrategiesFeature.cs b/src/modules/Elsa.Workflows.Core/CommitStates/CommitStrategiesFeature.cs index eeca573f39..1ca58a54e5 100644 --- a/src/modules/Elsa.Workflows.Core/CommitStates/CommitStrategiesFeature.cs +++ b/src/modules/Elsa.Workflows.Core/CommitStates/CommitStrategiesFeature.cs @@ -16,27 +16,27 @@ public void AddStandardStrategies() Add(new WorkflowExecutedWorkflowStrategy()); Add(new ActivityExecutingWorkflowStrategy()); Add(new ActivityExecutedWorkflowStrategy()); - + // Activity commit strategies. 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); @@ -44,7 +44,7 @@ public void Add(string displayName, string description, IWorkflowCommitStrategy registration.Metadata.Description = description; Add(registration); } - + public void Add(string name, string displayName, string description, IWorkflowCommitStrategy strategy) { var registration = ObjectRegistrationFactory.Describe(strategy); @@ -53,25 +53,25 @@ public void Add(string name, string displayName, string description, IWorkflowCo 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); @@ -79,7 +79,7 @@ public void Add(string displayName, string description, IActivityCommitStrategy registration.Metadata.Description = description; Add(registration); } - + public void Add(string name, string displayName, string description, IActivityCommitStrategy strategy) { var registration = ObjectRegistrationFactory.Describe(strategy); @@ -88,12 +88,32 @@ public void Add(string name, string displayName, string description, IActivityCo registration.Metadata.Description = description; Add(registration); } - + public void Add(ActivityCommitStrategyRegistration registration) { Services.Configure(options => options.ActivityCommitStrategies[registration.Metadata.Name] = registration); } - + + /// + /// Sets the specified workflow commit strategy as the global default. + /// The strategy will not be added to the registry and will only serve as a fallback when workflows do not specify their own strategy. + /// + /// The workflow commit strategy instance to use as the default. + public void SetDefaultWorkflowCommitStrategy(IWorkflowCommitStrategy strategy) + { + Services.Configure(options => options.DefaultWorkflowCommitStrategy = strategy); + } + + /// + /// Sets the specified activity commit strategy as the global default. + /// The strategy will not be added to the registry and will only serve as a fallback when activities do not specify their own strategy. + /// + /// The activity commit strategy instance to use as the default. + public void SetDefaultActivityCommitStrategy(IActivityCommitStrategy strategy) + { + Services.Configure(options => options.DefaultActivityCommitStrategy = strategy); + } + public override void Apply() { Services.AddSingleton(); diff --git a/src/modules/Elsa.Workflows.Core/CommitStates/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Core/CommitStates/Extensions/ModuleExtensions.cs index 9d2bf94c50..73464abee0 100644 --- a/src/modules/Elsa.Workflows.Core/CommitStates/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Extensions/ModuleExtensions.cs @@ -4,12 +4,46 @@ // ReSharper disable once CheckNamespace namespace Elsa.Extensions; +/// +/// Provides extension methods for configuring commit strategies on . +/// public static class WorkflowsFeatureCommitStateExtensions { + /// + /// Configures commit strategies for workflows. + /// + /// The workflows feature. + /// An optional configuration delegate for the commit strategies feature. + /// The workflows feature for chaining. public static WorkflowsFeature UseCommitStrategies(this WorkflowsFeature workflowsFeature, Action? configure = null) { workflowsFeature.Module.Use(configure); return workflowsFeature; } - + + /// + /// Sets the specified workflow commit strategy as the global default for all workflows that do not specify their own strategy. + /// The strategy will not be added to the registry and serves only as a fallback. + /// + /// The workflows feature. + /// The workflow commit strategy instance to use as the default. + /// The workflows feature for chaining. + public static WorkflowsFeature WithDefaultWorkflowCommitStrategy(this WorkflowsFeature workflowsFeature, IWorkflowCommitStrategy strategy) + { + workflowsFeature.Module.Use(feature => feature.SetDefaultWorkflowCommitStrategy(strategy)); + return workflowsFeature; + } + + /// + /// Sets the specified activity commit strategy as the global default for all activities that do not specify their own strategy. + /// The strategy will not be added to the registry and serves only as a fallback. + /// + /// The workflows feature. + /// The activity commit strategy instance to use as the default. + /// The workflows feature for chaining. + public static WorkflowsFeature WithDefaultActivityCommitStrategy(this WorkflowsFeature workflowsFeature, IActivityCommitStrategy strategy) + { + workflowsFeature.Module.Use(feature => feature.SetDefaultActivityCommitStrategy(strategy)); + return workflowsFeature; + } } \ 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 index cf607f9862..babd8e77dc 100644 --- a/src/modules/Elsa.Workflows.Core/CommitStates/Options/CommitStateOptions.cs +++ b/src/modules/Elsa.Workflows.Core/CommitStates/Options/CommitStateOptions.cs @@ -1,7 +1,29 @@ namespace Elsa.Workflows.CommitStates; +/// +/// Configuration options for commit state strategies. +/// public class CommitStateOptions { + /// + /// Gets or sets the workflow commit strategies. + /// public IDictionary WorkflowCommitStrategies { get; set; } = new Dictionary(); + + /// + /// Gets or sets the activity commit strategies. + /// public IDictionary ActivityCommitStrategies { get; set; } = new Dictionary(); + + /// + /// Gets or sets the default workflow commit strategy instance to use when a workflow does not specify its own. + /// This strategy is not added to the registry and serves only as a fallback. + /// + public IWorkflowCommitStrategy? DefaultWorkflowCommitStrategy { get; set; } + + /// + /// Gets or sets the default activity commit strategy instance to use when an activity does not specify its own. + /// This strategy is not added to the registry and serves only as a fallback. + /// + public IActivityCommitStrategy? DefaultActivityCommitStrategy { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs index 24ffb4939d..79cac9b2c8 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs @@ -5,6 +5,7 @@ using Elsa.Workflows.CommitStates; using Elsa.Workflows.Pipelines.ActivityExecution; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Middleware.Activities; @@ -22,11 +23,11 @@ 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, ICommitStrategyRegistry commitStrategyRegistry, ILogger logger) +public class DefaultActivityInvokerMiddleware(ActivityMiddlewareDelegate next, ICommitStrategyRegistry commitStrategyRegistry, IOptions commitStateOptions, ILogger logger) : IActivityExecutionMiddleware { private static readonly MethodInfo ExecuteAsyncMethodInfo = typeof(IActivity).GetMethod(nameof(IActivity.ExecuteAsync))!; - + /// public async ValueTask InvokeAsync(ActivityExecutionContext context) { @@ -65,7 +66,7 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) // Execute activity. await ExecuteActivityAsync(context); - + var currentActivityStatus = context.Status; var activityDidComplete = previousActivityStatus != ActivityStatus.Completed && currentActivityStatus == ActivityStatus.Completed; @@ -86,7 +87,7 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) // Invoke next middleware. await next(context); - + // If the activity completed, send a notification. if (activityDidComplete) { @@ -105,7 +106,7 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) /// protected virtual async ValueTask ExecuteActivityAsync(ActivityExecutionContext context) { - var executeDelegate = context.WorkflowExecutionContext.ExecuteDelegate + var executeDelegate = context.WorkflowExecutionContext.ExecuteDelegate ?? (ExecuteActivityDelegate)Delegate.CreateDelegate(typeof(ExecuteActivityDelegate), context.Activity, ExecuteAsyncMethodInfo); await executeDelegate(context); @@ -129,7 +130,11 @@ private async Task EvaluateInputPropertiesAsync(ActivityExecutionContext context private bool ShouldCommit(ActivityExecutionContext context, ActivityLifetimeEvent lifetimeEvent) { var strategyName = context.Activity.GetCommitStrategy(); - var strategy = string.IsNullOrWhiteSpace(strategyName) ? null : commitStrategyRegistry.FindActivityStrategy(strategyName); + + IActivityCommitStrategy? strategy = !string.IsNullOrWhiteSpace(strategyName) + ? commitStrategyRegistry.FindActivityStrategy(strategyName) + : commitStateOptions.Value.DefaultActivityCommitStrategy; + var commitAction = CommitAction.Default; if (strategy != null) @@ -147,7 +152,10 @@ private bool ShouldCommit(ActivityExecutionContext context, ActivityLifetimeEven case CommitAction.Default: { var workflowStrategyName = context.WorkflowExecutionContext.Workflow.Options.CommitStrategyName; - var workflowStrategy = string.IsNullOrWhiteSpace(workflowStrategyName) ? null : commitStrategyRegistry.FindWorkflowStrategy(workflowStrategyName); + + IWorkflowCommitStrategy? workflowStrategy = !string.IsNullOrWhiteSpace(workflowStrategyName) + ? commitStrategyRegistry.FindWorkflowStrategy(workflowStrategyName) + : commitStateOptions.Value.DefaultWorkflowCommitStrategy; if (workflowStrategy == null) return false; diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs index 26594ca937..f939d1e64a 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs @@ -3,6 +3,7 @@ using Elsa.Workflows.Models; using Elsa.Workflows.Options; using Elsa.Workflows.Pipelines.WorkflowExecution; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Middleware.Workflows; @@ -20,7 +21,7 @@ public static class UseActivitySchedulerMiddlewareExtensions /// /// A workflow execution middleware component that executes scheduled work items. /// -public class DefaultActivitySchedulerMiddleware(WorkflowMiddlewareDelegate next, IActivityInvoker activityInvoker, ICommitStrategyRegistry commitStrategyRegistry) : WorkflowExecutionMiddleware(next) +public class DefaultActivitySchedulerMiddleware(WorkflowMiddlewareDelegate next, IActivityInvoker activityInvoker, ICommitStrategyRegistry commitStrategyRegistry, IOptions commitStateOptions) : WorkflowExecutionMiddleware(next) { /// public override async ValueTask InvokeAsync(WorkflowExecutionContext context) @@ -29,19 +30,19 @@ 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. if (context.CancellationToken.IsCancellationRequested) break; - + var currentWorkItem = scheduler.Take(); await ExecuteWorkItemAsync(context, currentWorkItem); } - + await Next(context); - + if (context.Status == WorkflowStatus.Running) context.TransitionTo(context.AllActivitiesCompleted() ? WorkflowSubStatus.Finished : WorkflowSubStatus.Suspended); } @@ -59,18 +60,20 @@ private async Task ExecuteWorkItemAsync(WorkflowExecutionContext context, Activi 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) + IWorkflowCommitStrategy? strategy = !string.IsNullOrWhiteSpace(strategyName) + ? commitStrategyRegistry.FindWorkflowStrategy(strategyName) + : commitStateOptions.Value.DefaultWorkflowCommitStrategy; + + if (strategy == null) return; - + var strategyContext = new WorkflowCommitStateStrategyContext(context, lifetimeEvent); var commitAction = strategy.ShouldCommit(strategyContext); - + if (commitAction is CommitAction.Commit) await context.CommitAsync(); } diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs index 30277beb9a..cf7a47820c 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs @@ -11,6 +11,7 @@ using Elsa.Workflows.Runtime.Stimuli; using JetBrains.Annotations; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Runtime.Middleware.Activities; @@ -24,8 +25,9 @@ public class BackgroundActivityInvokerMiddleware( IIdentityGenerator identityGenerator, IBackgroundActivityScheduler backgroundActivityScheduler, ICommitStrategyRegistry commitStrategyRegistry, - IMediator mediator) - : DefaultActivityInvokerMiddleware(next, commitStrategyRegistry, logger) + IMediator mediator, + IOptions commitStateOptions) + : DefaultActivityInvokerMiddleware(next, commitStrategyRegistry, commitStateOptions, logger) { internal static string GetBackgroundActivityOutputKey(string activityNodeId) => $"__BackgroundActivityOutput:{activityNodeId}"; internal static string GetBackgroundActivityOutcomesKey(string activityNodeId) => $"__BackgroundActivityOutcomes:{activityNodeId}"; @@ -129,7 +131,7 @@ private static bool GetTaskRunAsynchronously(ActivityExecutionContext context) } private static bool GetIsBackgroundExecution(ActivityExecutionContext context) => context.TransientProperties.ContainsKey(BackgroundActivityExecutionContextExtensions.IsBackgroundExecution); - + /// /// If the input contains captured output from the background activity invoker, apply that to the execution context. /// @@ -183,7 +185,7 @@ private void CaptureBookmarkData(ActivityExecutionContext context) context.WorkflowExecutionContext.Properties.Remove(bookmarksKey); } - + private void CapturePropertiesIfAny(ActivityExecutionContext context) { var activity = context.Activity; @@ -195,7 +197,7 @@ private void CapturePropertiesIfAny(ActivityExecutionContext context) if (capturedProperties == null) return; - foreach (var property in capturedProperties) + foreach (var property in capturedProperties) context.Properties[property.Key] = property.Value; } diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/SimpleWorkflowWithoutActivityCommitStrategy.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/SimpleWorkflowWithoutActivityCommitStrategy.cs new file mode 100644 index 0000000000..111008110e --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/SimpleWorkflowWithoutActivityCommitStrategy.cs @@ -0,0 +1,19 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.DefaultActivityCommitStrategy; + +public class SimpleWorkflowWithoutActivityCommitStrategy : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Sequence + { + Activities = + { + new WriteLine("Activity 1"), + new WriteLine("Activity 2"), + new WriteLine("Activity 3") + } + }; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/Tests.cs new file mode 100644 index 0000000000..38fdb5210d --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/Tests.cs @@ -0,0 +1,201 @@ +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.CommitStates.Strategies; +using Elsa.Workflows.CommitStates.Tasks; +using Elsa.Workflows.IntegrationTests.SharedHelpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.DefaultActivityCommitStrategy; + +public class Tests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public Tests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact(DisplayName = "Activity without explicit strategy uses default commit strategy")] + public async Task ActivityUsesDefaultCommitStrategy() + { + // Arrange + var commitTracker = new CommitTracker(); + var defaultStrategy = new ExecutedActivityStrategy(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => + { + workflows.WithDefaultActivityCommitStrategy(defaultStrategy); + workflows.CommitStateHandler = _ => commitTracker; + }) + ) + .AddWorkflow() + .Build(); + + var options = services.GetRequiredService>(); + var workflowRunner = services.GetRequiredService(); + + // Act + var result = await workflowRunner.RunAsync(); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.NotNull(options.Value.DefaultActivityCommitStrategy); + Assert.Same(defaultStrategy, options.Value.DefaultActivityCommitStrategy); + + // 6 commits: 3 WriteLine activities + 3 Sequence (composite) completion checks + Assert.Equal(6, commitTracker.CommitCount); + } + + [Fact(DisplayName = "Activity-specific strategy overrides default commit strategy")] + public async Task ActivitySpecificStrategyOverridesDefault() + { + // Arrange + var commitTracker = new CommitTracker(); + var defaultStrategy = new ExecutedActivityStrategy(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => + { + workflows.WithDefaultActivityCommitStrategy(defaultStrategy); + workflows.UseCommitStrategies(commitStrategies => commitStrategies.AddStandardStrategies()); + workflows.CommitStateHandler = _ => commitTracker; + }) + ) + .AddWorkflow() + .Build(); + + var options = services.GetRequiredService>(); + var workflowRunner = services.GetRequiredService(); + + // Act + var result = await workflowRunner.RunAsync(); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.NotNull(options.Value.DefaultActivityCommitStrategy); + Assert.Same(defaultStrategy, options.Value.DefaultActivityCommitStrategy); + + // 4 commits: First activity with ExecutingActivity (before), second with default ExecutedActivity (after), + // plus Sequence composite completions + Assert.Equal(4, commitTracker.CommitCount); + } + + [Fact(DisplayName = "No commits occur when no default strategy and no activity strategy")] + public async Task NoCommitsWithoutAnyStrategy() + { + // Arrange + var commitTracker = new CommitTracker(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => workflows.CommitStateHandler = _ => commitTracker) + ) + .AddWorkflow() + .Build(); + + var options = services.GetRequiredService>(); + var workflowRunner = services.GetRequiredService(); + + // Act + var result = await workflowRunner.RunAsync(); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Null(options.Value.DefaultActivityCommitStrategy); + + // 1 commit: Only the final commit from WorkflowRunner (no middleware commits) + Assert.Equal(1, commitTracker.CommitCount); + } + + [Fact(DisplayName = "Default activity strategy is not visible in commit strategy registry")] + public void DefaultStrategyNotInRegistry() + { + // Arrange + var defaultStrategy = new ExecutedActivityStrategy(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => workflows + .WithDefaultActivityCommitStrategy(defaultStrategy) + ) + ) + .Build(); + + var registry = services.GetRequiredService(); + var options = services.GetRequiredService>(); + + // Act + var activityStrategies = registry.ListActivityStrategyRegistrations().ToList(); + + // Assert + Assert.Empty(activityStrategies); + Assert.NotNull(options.Value.DefaultActivityCommitStrategy); + Assert.Same(defaultStrategy, options.Value.DefaultActivityCommitStrategy); + } + + [Fact(DisplayName = "Default activity strategy with standard strategies does not duplicate")] + public async Task DefaultStrategyWithStandardStrategiesNoDuplicate() + { + // Arrange + var defaultStrategy = new ExecutedActivityStrategy(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => workflows + .WithDefaultActivityCommitStrategy(defaultStrategy) + .UseCommitStrategies(commitStrategies => commitStrategies.AddStandardStrategies()) + ) + ) + .Build(); + + var registry = services.GetRequiredService(); + + // Manually populate the registry + var startupTask = new PopulateCommitStrategyRegistry( + registry, + services.GetRequiredService>() + ); + await startupTask.ExecuteAsync(CancellationToken.None); + + // Act + var activityStrategies = registry.ListActivityStrategyRegistrations().ToList(); + + // Assert - 4 standard activity strategies (no duplication from default) + Assert.Equal(4, activityStrategies.Count); + } + + [Fact(DisplayName = "Default workflow strategy is used when no default activity strategy is specified")] + public async Task DefaultWorkflowStrategyWithoutDefaultActivityStrategy() + { + // Arrange + var commitTracker = new CommitTracker(); + var defaultWorkflowStrategy = new ActivityExecutedWorkflowStrategy(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => + { + workflows.WithDefaultWorkflowCommitStrategy(defaultWorkflowStrategy); + workflows.CommitStateHandler = _ => commitTracker; + }) + ) + .AddWorkflow() + .Build(); + + var options = services.GetRequiredService>(); + var workflowRunner = services.GetRequiredService(); + + // Act + var result = await workflowRunner.RunAsync(); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.NotNull(options.Value.DefaultWorkflowCommitStrategy); + Assert.Same(defaultWorkflowStrategy, options.Value.DefaultWorkflowCommitStrategy); + Assert.Null(options.Value.DefaultActivityCommitStrategy); + + // 6 commits: ActivityExecutedWorkflowStrategy commits after each activity completion (3 WriteLine + 3 Sequence) + Assert.Equal(6, commitTracker.CommitCount); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/WorkflowWithExplicitActivityCommitStrategy.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/WorkflowWithExplicitActivityCommitStrategy.cs new file mode 100644 index 0000000000..f6c8c9039f --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultActivityCommitStrategy/WorkflowWithExplicitActivityCommitStrategy.cs @@ -0,0 +1,22 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.DefaultActivityCommitStrategy; + +public class WorkflowWithExplicitActivityCommitStrategy : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var writeLineWithStrategy = new WriteLine("Activity with strategy"); + writeLineWithStrategy.CommitStrategy = "ExecutingActivity"; // Uses standard "Commit Before" strategy + + builder.Root = new Sequence + { + Activities = + { + writeLineWithStrategy, + new WriteLine("Activity 2") + } + }; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/SimpleWorkflowWithoutWorkflowCommitStrategy.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/SimpleWorkflowWithoutWorkflowCommitStrategy.cs new file mode 100644 index 0000000000..92770bfa57 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/SimpleWorkflowWithoutWorkflowCommitStrategy.cs @@ -0,0 +1,22 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.DefaultWorkflowCommitStrategy; + +/// +/// A simple workflow that does not specify an explicit commit strategy. +/// +public class SimpleWorkflowWithoutWorkflowCommitStrategy : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Sequence + { + Activities = + { + new WriteLine("Activity 1"), + new WriteLine("Activity 2"), + new WriteLine("Activity 3") + } + }; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/Tests.cs new file mode 100644 index 0000000000..9a99956eeb --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/Tests.cs @@ -0,0 +1,167 @@ +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.CommitStates.Strategies; +using Elsa.Workflows.IntegrationTests.SharedHelpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.DefaultWorkflowCommitStrategy; + +public class Tests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public Tests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact(DisplayName = "Workflow uses default workflow commit strategy when no explicit workflow commit strategy is set")] + public async Task WorkflowUsesDefaultWorkflowCommitStrategy() + { + // Arrange + var commitTracker = new CommitTracker(); + var defaultStrategy = new ActivityExecutedWorkflowStrategy(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => + { + workflows.WithDefaultWorkflowCommitStrategy(defaultStrategy); + workflows.CommitStateHandler = _ => commitTracker; + }) + ) + .AddWorkflow() + .Build(); + + var options = services.GetRequiredService>(); + var workflowRunner = services.GetRequiredService(); + + // Act + var result = await workflowRunner.RunAsync(); + + // Assert - workflow should finish successfully with default strategy configured + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.NotNull(options.Value.DefaultWorkflowCommitStrategy); + Assert.Same(defaultStrategy, options.Value.DefaultWorkflowCommitStrategy); + + // Verify exact commit count: ActivityExecutedWorkflowStrategy commits after each activity completes + // With 3 WriteLine activities in a Sequence, this results in exactly 6 commits due to + // how composite activities and workflow completion signals interact + Assert.Equal(6, commitTracker.CommitCount); + } + + [Fact(DisplayName = "Workflow-specific strategy overrides default workflow commit strategy")] + public async Task WorkflowSpecificStrategyOverridesDefault() + { + // Arrange + var commitTracker = new CommitTracker(); + var defaultStrategy = new ActivityExecutedWorkflowStrategy(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => workflows + .WithDefaultWorkflowCommitStrategy(defaultStrategy) + .UseCommitStrategies(commitStrategies => commitStrategies.AddStandardStrategies()) + .CommitStateHandler = _ => commitTracker + ) + ) + .AddWorkflow() + .Build(); + + var workflowRunner = services.GetRequiredService(); + var options = services.GetRequiredService>(); + + // Act + var result = await workflowRunner.RunAsync(); + + // Assert - workflow should complete successfully + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + // Default strategy should be configured (available for workflows without explicit strategy) + Assert.Same(defaultStrategy, options.Value.DefaultWorkflowCommitStrategy); + + // Verify the workflow used its explicit "WorkflowExecuting" strategy (commits before workflow starts) + // 1 commit at the beginning before any activities execute + Assert.Equal(1, commitTracker.CommitCount); + } + + [Fact(DisplayName = "No commits occur when no default workflow commit strategy and no workflow strategy")] + public async Task NoCommitsWithoutAnyWorkflowCommitStrategy() + { + // Arrange + var commitTracker = new CommitTracker(); + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => + { + workflows.CommitStateHandler = _ => commitTracker; + }) + ) + .AddWorkflow() + .Build(); + + var workflowRunner = services.GetRequiredService(); + var options = services.GetRequiredService>(); + + // Act + var result = await workflowRunner.RunAsync(); + + // Assert - workflow should still complete even without commit strategy + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + // No default strategy should be configured + Assert.Null(options.Value.DefaultWorkflowCommitStrategy); + + // Verify no commits occurred during workflow execution (only final commit from WorkflowRunner) + Assert.Equal(1, commitTracker.CommitCount); + } + + [Fact(DisplayName = "Default workflow strategy is not visible in commit strategy registry")] + public void DefaultWorkflowStrategyNotInRegistry() + { + // Arrange + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => workflows + .WithDefaultWorkflowCommitStrategy(new ActivityExecutedWorkflowStrategy()) + ) + ) + .Build(); + + var registry = services.GetRequiredService(); + + // Act + var registeredStrategies = registry.ListWorkflowStrategyRegistrations().ToList(); + + // Assert - default strategy should not be in the registry + Assert.Empty(registeredStrategies); + } + + [Fact(DisplayName = "Default workflow strategy with standard strategies does not duplicate")] + public void DefaultWorkflowStrategyWithStandardStrategiesNoDuplicate() + { + // Arrange + var services = new TestApplicationBuilder(_testOutputHelper) + .ConfigureElsa(elsa => elsa + .UseWorkflows(workflows => workflows + .WithDefaultWorkflowCommitStrategy(new ActivityExecutedWorkflowStrategy()) + .UseCommitStrategies(commitStrategies => commitStrategies.AddStandardStrategies()) + ) + ) + .Build(); + + // Manually trigger the PopulateCommitStrategyRegistry startup task + var options = services.GetRequiredService>(); + var registry = services.GetRequiredService(); + + foreach (var strategy in options.Value.WorkflowCommitStrategies.Values) + registry.RegisterStrategy(strategy); + foreach (var strategy in options.Value.ActivityCommitStrategies.Values) + registry.RegisterStrategy(strategy); + + // Act + var registeredStrategies = registry.ListWorkflowStrategyRegistrations().ToList(); + + // Assert - should only have the 4 standard strategies, not 5 + Assert.Equal(4, registeredStrategies.Count); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/WorkflowWithExplicitWorkflowCommitStrategy.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/WorkflowWithExplicitWorkflowCommitStrategy.cs new file mode 100644 index 0000000000..be235e3e57 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DefaultWorkflowCommitStrategy/WorkflowWithExplicitWorkflowCommitStrategy.cs @@ -0,0 +1,23 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.DefaultWorkflowCommitStrategy; + +/// +/// A workflow that explicitly sets a commit strategy, which should override the default. +/// +public class WorkflowWithExplicitWorkflowCommitStrategy : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + builder.WorkflowOptions.CommitStrategyName = "WorkflowExecuting"; + + builder.Root = new Sequence + { + Activities = + { + new WriteLine("Activity 1"), + new WriteLine("Activity 2") + } + }; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/SharedHelpers/CommitTracker.cs b/test/integration/Elsa.Workflows.IntegrationTests/SharedHelpers/CommitTracker.cs new file mode 100644 index 0000000000..fd26f87492 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/SharedHelpers/CommitTracker.cs @@ -0,0 +1,26 @@ +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.State; + +namespace Elsa.Workflows.IntegrationTests.SharedHelpers; + +/// +/// Test helper to track commit invocations +/// +public class CommitTracker : ICommitStateHandler +{ + public int CommitCount { get; private set; } + + public Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, CancellationToken cancellationToken = default) + { + CommitCount++; + + return Task.CompletedTask; + } + + public Task CommitAsync(WorkflowExecutionContext workflowExecutionContext, WorkflowState workflowState, CancellationToken cancellationToken = default) + { + CommitCount++; + + return Task.CompletedTask; + } +}