diff --git a/src/common/Elsa.Testing.Shared/ServiceProviderExtensions.cs b/src/common/Elsa.Testing.Shared/ServiceProviderExtensions.cs index a35e9388e..8aea5844c 100644 --- a/src/common/Elsa.Testing.Shared/ServiceProviderExtensions.cs +++ b/src/common/Elsa.Testing.Shared/ServiceProviderExtensions.cs @@ -7,6 +7,7 @@ using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Models; using Elsa.Workflows.Runtime.Contracts; using Elsa.Workflows.Runtime.Requests; +using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Testing.Shared; @@ -14,6 +15,7 @@ namespace Elsa.Testing.Shared; /// /// Provides extension methods for . /// +[PublicAPI] public static class ServiceProviderExtensions { /// diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs index 7099baac8..c1ab586dc 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs @@ -20,14 +20,14 @@ public class FlowJoin : Activity, IJoinNode public FlowJoin([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line) { } - + /// /// The join mode determines whether this activity should continue as soon as one inbound path comes in (Wait Any), or once all inbound paths have executed (Wait All). /// [Input( Description = "The join mode determines whether this activity should continue as soon as one inbound path comes in (Wait Any), or once all inbound paths have executed (Wait All).", DefaultValue = FlowJoinMode.WaitAny - )] + )] public Input Mode { get; set; } = new(FlowJoinMode.WaitAny); /// @@ -50,14 +50,12 @@ public class FlowJoin : Activity, IJoinNode await context.CompleteActivityAsync(); break; case FlowJoinMode.WaitAny: - // Only complete if we haven't already executed. + // If this activity was already executed, complete it with the AlreadyCompleted result. This will prevent the Flowchart activity from scheduling its siblings again. var alreadyExecuted = inboundActivities.Max(x => flowScope.GetExecutionCount(x)) == executionCount; + var result = alreadyExecuted ? new AlreadyCompleted() : default; - if (!alreadyExecuted) - { - await context.CompleteActivityAsync(); - await ClearBookmarksAsync(flowchart, context); - } + await context.CompleteActivityAsync(result); + await ClearBookmarksAsync(flowchart, context); break; } } @@ -71,7 +69,7 @@ public class FlowJoin : Activity, IJoinNode var inboundActivityExecutionContexts = workflowExecutionContext.ActiveActivityExecutionContexts.Where(x => inboundActivities.Contains(x.Activity)).ToList(); // Cancel each inbound activity. - foreach (var activityExecutionContext in inboundActivityExecutionContexts) + foreach (var activityExecutionContext in inboundActivityExecutionContexts) await activityExecutionContext.CancelActivityAsync(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs index 1fa9a8d4a..13ed23122 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs @@ -57,13 +57,15 @@ public class Flowchart : Container var childContext = context.ChildContext; var completedActivity = childContext.Activity; var result = context.Result; - + var alreadyCompleted = result is AlreadyCompleted; + // If specific outcomes were provided by the completed activity, use them to find the connection to the next activity. Func outboundConnectionsQuery = result is Outcomes outcomes ? connection => connection.Source.Activity == completedActivity && outcomes.Names.Contains(connection.Source.Port) : connection => connection.Source.Activity == completedActivity; - var outboundConnections = Connections.Where(outboundConnectionsQuery).ToList(); + // Only query the outbound connections if the completed activity wasn't already completed. + var outboundConnections = alreadyCompleted ? new List() : Connections.Where(outboundConnectionsQuery).ToList(); var children = outboundConnections.Select(x => x.Target.Activity).ToList(); var scope = targetContext.GetProperty(ScopeProperty, () => new FlowScope()); diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/AlreadyCompleted.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/AlreadyCompleted.cs new file mode 100644 index 000000000..7da29815e --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/AlreadyCompleted.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows.Core.Activities.Flowchart.Models; + +/// +/// Represents an activity completion result that indicates that the activity has already been completed. See and . +/// +public record AlreadyCompleted; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/Outcomes.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/Outcomes.cs index a8087f8dd..c72d09e02 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/Outcomes.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/Outcomes.cs @@ -1,3 +1,7 @@ namespace Elsa.Workflows.Core.Activities.Flowchart.Models; +/// +/// Represents a list of outcomes that can be send when completing an activity. This information is used by . +/// +/// A list of outcome names. public record Outcomes(params string[] Names); \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj b/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj index e8ccb4ac4..321126b3a 100644 --- a/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj +++ b/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj @@ -96,6 +96,11 @@ Always + + Always + + + diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/ExplicitJoins/ExplicitJoinWaitAnyTests.cs b/test/integration/Elsa.IntegrationTests/Scenarios/ExplicitJoins/ExplicitJoinWaitAnyTests.cs new file mode 100644 index 000000000..06e944b05 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/ExplicitJoins/ExplicitJoinWaitAnyTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Elsa.Testing.Shared; +using Elsa.Workflows.Core; +using Xunit; +using Xunit.Abstractions; + +namespace Elsa.IntegrationTests.Scenarios.ExplicitJoins; + +public class ExplicitJoinWaitAnyTests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _services; + + public ExplicitJoinWaitAnyTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); + } + + [Fact(DisplayName = "Workflows with explicit joins using WaitAny wait for any of the specified activities to complete and complete the workflow.")] + public async Task Test1() + { + // Populate registries. + await _services.PopulateRegistriesAsync(); + + // Import workflow. + var fileName = "Scenarios/ExplicitJoins/Workflows/flow-join-any.json"; + var workflowDefinition = await _services.ImportWorkflowDefinitionAsync(fileName); + + // Execute. + var workflowState = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId); + + // Assert expected output. + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Equal(new[] { "Start", "End" }, lines); + + // Assert expected workflow status. + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/ExplicitJoins/Workflows/flow-join-any.json b/test/integration/Elsa.IntegrationTests/Scenarios/ExplicitJoins/Workflows/flow-join-any.json new file mode 100644 index 000000000..6610377a6 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/ExplicitJoins/Workflows/flow-join-any.json @@ -0,0 +1,245 @@ +{ + "id": "2484d1b14e2348f7a916ecef042536e6", + "definitionId": "7204b870946b4968aa786a2c91431242", + "name": "Flow Join Any", + "createdAt": "2023-09-08T19:07:08.435872+00:00", + "version": 1, + "toolVersion": "3.0.0.0", + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": false, + "options": { + "autoUpdateConsumingWorkflows": false + }, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "F44h770hvkSaOWI_jjbM_A", + "metadata": {}, + "customProperties": { + "source": "FlowchartJsonConverter.cs:45", + "NotFoundConnectionsKey": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "activities": [ + { + "mode": { + "typeName": "Elsa.Workflows.Core.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core", + "expression": { + "type": "Literal", + "value": "WaitAny" + }, + "memoryReference": { + "id": "ImdIbHXtHkiLlrExFlW51A:input-0" + } + }, + "id": "ImdIbHXtHkiLlrExFlW51A", + "name": "FlowJoin1", + "type": "Elsa.FlowJoin", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": -180, + "y": -320 + }, + "size": { + "width": 136.859375, + "height": 50 + } + } + } + }, + { + "mode": { + "typeName": "Elsa.Workflows.Core.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core", + "expression": { + "type": "Literal", + "value": "WaitAny" + }, + "memoryReference": { + "id": "cxnS1whoCESvwwPyBnno_Q:input-0" + } + }, + "id": "cxnS1whoCESvwwPyBnno_Q", + "name": "FlowJoin2", + "type": "Elsa.FlowJoin", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": -180, + "y": -80 + }, + "size": { + "width": 136.859375, + "height": 50 + } + } + } + }, + { + "mode": { + "typeName": "Elsa.Workflows.Core.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core", + "expression": { + "type": "Literal", + "value": "WaitAny" + }, + "memoryReference": { + "id": "H0apwXYdoESn2TJ5T_3xAg:input-0" + } + }, + "id": "H0apwXYdoESn2TJ5T_3xAg", + "name": "FlowJoin3", + "type": "Elsa.FlowJoin", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 40, + "y": -194 + }, + "size": { + "width": 136.859375, + "height": 50 + } + } + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Start" + }, + "memoryReference": { + "id": "Kn9d-EVn2EiaYJqHd8VcmQ:input-0" + } + }, + "id": "Kn9d-EVn2EiaYJqHd8VcmQ", + "name": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": -393.5, + "y": -194 + }, + "size": { + "width": 139.296875, + "height": 50 + } + } + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "End" + }, + "memoryReference": { + "id": "haHoM3A5EU6aYu_8dCYaYg:input-0" + } + }, + "id": "haHoM3A5EU6aYu_8dCYaYg", + "name": "WriteLine2", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 310.5, + "y": -194 + }, + "size": { + "width": 139.296875, + "height": 50 + } + } + } + } + ], + "connections": [ + { + "source": { + "activity": "Kn9d-EVn2EiaYJqHd8VcmQ", + "port": "Done" + }, + "target": { + "activity": "ImdIbHXtHkiLlrExFlW51A", + "port": "In" + } + }, + { + "source": { + "activity": "Kn9d-EVn2EiaYJqHd8VcmQ", + "port": "Done" + }, + "target": { + "activity": "cxnS1whoCESvwwPyBnno_Q", + "port": "In" + } + }, + { + "source": { + "activity": "ImdIbHXtHkiLlrExFlW51A", + "port": "Done" + }, + "target": { + "activity": "H0apwXYdoESn2TJ5T_3xAg", + "port": "In" + } + }, + { + "source": { + "activity": "cxnS1whoCESvwwPyBnno_Q", + "port": "Done" + }, + "target": { + "activity": "H0apwXYdoESn2TJ5T_3xAg", + "port": "In" + } + }, + { + "source": { + "activity": "H0apwXYdoESn2TJ5T_3xAg", + "port": "Done" + }, + "target": { + "activity": "haHoM3A5EU6aYu_8dCYaYg", + "port": "In" + } + } + ] + } +} \ No newline at end of file