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