diff --git a/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md b/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
index b1143eca0..f6f279a50 100644
--- a/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
+++ b/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
@@ -24,27 +24,38 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas
```csharp
namespace Elsa.Workflows.Activities.Flowchart.Models;
+ ///
+ /// Specifies the strategy for handling multiple inbound execution paths in a workflow.
+ /// Uses flow-based terminology to describe merge behavior.
+ ///
public enum MergeMode
{
///
- /// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths.
- /// Suitable for flexible, unstructured merges where optional branches shouldn't block.
- ///
- None,
-
- ///
- /// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
- /// Use for required synchronization points.
- ///
- Converge,
-
- ///
- /// Schedule on each arriving token, allowing multiple executions if supported.
+ /// Flows freely when possible, ignoring dead/untaken paths.
+ /// Opportunistic execution based on upstream completion.
///
Stream,
///
- /// Schedule on the first arriving token, block or cancel others.
+ /// Merges only the activated/flowing inbound branches.
+ /// Waits for all branches that received tokens, ignoring unactivated ones.
+ ///
+ Merge,
+
+ ///
+ /// Converges all inbound paths, requiring every connection to execute.
+ /// Strictest mode - will block on dead/untaken paths.
+ ///
+ Converge,
+
+ ///
+ /// Cascades execution for each arriving token independently.
+ /// Allows multiple concurrent executions (one per arriving token).
+ ///
+ Cascade,
+
+ ///
+ /// Races inbound branches, executing on first arrival and blocking others.
///
Race
}
@@ -62,14 +73,14 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas
```csharp
switch (mergeMode)
{
- case MergeMode.Stream:
+ case MergeMode.Cascade:
case MergeMode.Race:
- // Existing logic: Schedule on arrival, block others for Race.
+ // Schedule on arrival; for Race, block/cancel others.
// ...
break;
- case MergeMode.Converge:
- // Strict check: Wait for all forward inbounds if >1; else schedule immediately.
+ case MergeMode.Merge:
+ // Wait for tokens from all forward inbound connections (activated branches only).
var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
if (inboundConnections.Count > 1)
{
@@ -82,9 +93,23 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas
}
break;
- case MergeMode.None:
+ case MergeMode.Converge:
+ // Strictest mode: Wait for tokens from ALL inbound connections (forward + backward).
+ var allInboundConnections = flowGraph.GetInboundConnections(targetActivity);
+ if (allInboundConnections.Count > 1)
+ {
+ var hasAllTokens = allInboundConnections.All(inbound => /* token check */);
+ if (hasAllTokens) await flowContext.ScheduleActivityAsync(...);
+ }
+ else
+ {
+ await flowContext.ScheduleActivityAsync(...);
+ }
+ break;
+
+ case MergeMode.Stream:
default:
- // Approximation: Schedule if no unconsumed tokens to inbound sources.
+ // Flows freely - approximation that proceeds when upstream completes.
var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
var hasUnconsumed = inboundConnections.Any(inbound => /* source token check */);
if (!hasUnconsumed) await flowContext.ScheduleActivityAsync(...);
@@ -104,10 +129,10 @@ Flowchart execution starts with scheduling the root/start activity. As activitie
This ensures acyclic forward flow with support for backward loops, using tokens to track control without global state beyond the list.
### Merge Modes Explained
-Each mode defines how tokens from multiple inbounds are synchronized:
+Each mode defines how tokens from multiple inbounds are synchronized using flow-based terminology:
-- **None (Default/Flexible Merge)**:
- - **Behavior**: Schedules if there are no unconsumed tokens *to the sources* of inbounds (i.e., all upstream activities have completed, treating dead paths as "done").
+- **Stream (Flexible/Opportunistic Flow)**:
+ - **Behavior**: Flows freely when possible, ignoring dead/untaken paths. Schedules if there are no unconsumed tokens *to the sources* of inbounds (i.e., all upstream activities have completed, treating dead paths as "done").
- **When to Use**: Flexible merges in unstructured flows; optional/exclusive branches (e.g., switch defaults) shouldn't block.
- **Scenarios**:
- **Forks with Untaken Paths**: Proceeds after active branches (e.g., in complex switch with dangling default).
@@ -115,18 +140,26 @@ Each mode defines how tokens from multiple inbounds are synchronized:
- **Dead Paths**: Ignores untaken outcomes; no blocking.
- **Example**: In a switch with MatchAny, untaken default doesn't hang the merge.
-- **Converge (Strict Synchronization)**:
- - **Behavior**: Requires unconsumed, non-blocked tokens from *all* forward inbounds. For <=1 forward, schedules immediately (loop/sequential friendly).
- - **When to Use**: Required "all must happen" joins; block if any branch untaken.
+- **Merge (Activated Branches Synchronization)**:
+ - **Behavior**: Merges only activated/flowing inbound branches. Requires unconsumed, non-blocked tokens from *all* forward inbounds that received tokens. For <=1 forward, schedules immediately (loop/sequential friendly).
+ - **When to Use**: Synchronization points where only activated paths matter; block if any activated branch hasn't completed.
- **Scenarios**:
- - **Conditional Forks**: Blocks downstream if e.g., decision returns false and subsequent activities are connected to the true branch.
+ - **Conditional Forks**: Blocks downstream if decision returns false and subsequent activities are connected to the true branch, but only waits for activated branches.
- **Loops**: Works if forward inbounds <=1; reschedules on backward tokens.
- - **Dead Paths**: Blocks (desired for safety).
- - **Example**: Converge after parallel approvals—only proceed if all complete.
+ - **Dead Paths**: Ignores untaken branches; waits only for activated ones.
+ - **Example**: Merge after parallel approvals—only proceed if all activated approval branches complete.
-- **Stream (Per-Token Execution)**:
- - **Behavior**: Schedules on each arriving token; may allow multiple concurrent executions of the target.
- - **When to Use**: Streaming merges where each branch triggers independently (e.g., event streams).
+- **Converge (Strictest - All Paths Required)**:
+ - **Behavior**: Converges ALL inbound paths, requiring every connection to execute (forward AND backward). Most strict mode.
+ - **When to Use**: When every single inbound path must execute before proceeding, regardless of activation status.
+ - **Scenarios**:
+ - **Strict Barriers**: Forces all possible paths to complete before proceeding.
+ - **Dead Paths**: Blocks on dead/untaken paths (desired for maximum safety).
+ - **Example**: Critical synchronization point requiring absolute completion of all defined paths.
+
+- **Cascade (Per-Token Execution)**:
+ - **Behavior**: Cascades execution for each arriving token independently; may allow multiple concurrent executions of the target.
+ - **When to Use**: Streaming scenarios where each branch should trigger separate processing (e.g., event streams).
- **Scenarios**:
- **Forks**: Executes target per branch.
- **Loops**: Executes per iteration.
@@ -134,8 +167,8 @@ Each mode defines how tokens from multiple inbounds are synchronized:
- **Example**: Logging each branch outcome separately.
- **Race (First-Wins)**:
- - **Behavior**: Schedules on first token; blocks/cancels others (e.g., via blocked tokens and ancestor cancellation).
- - **When to Use**: Racing conditions (e.g., first response wins).
+ - **Behavior**: Races inbound branches; schedules on first token, blocks/cancels others (e.g., via blocked tokens and ancestor cancellation).
+ - **When to Use**: Racing conditions where first result wins (e.g., first response).
- **Scenarios**:
- **Forks**: Only first branch proceeds.
- **Loops**: May race iterations if concurrent.
@@ -143,12 +176,12 @@ Each mode defines how tokens from multiple inbounds are synchronized:
- **Example**: Waiting for fastest API response; cancel slower ones.
### Handling Common Scenarios
-
+
- **Simple Sequential**: Any mode schedules on token arrival (single inbound).
-- **Fork-Join with Condition**: Converge blocks on false; None proceeds.
-- **Looping Construct**: All modes work; Converge uses count check to avoid strictness.
-- **Switch with Dangling Branches**: None ignores untaken; Converge blocks if required.
-- **BPMN Alignment**: None ≈ XOR/OR-join (flexible); Converge ≈ AND-join (strict); Race ≈ Event-based; Stream ≈ partial OR.
+- **Fork-Join with Condition**: Merge waits for activated branches; Converge blocks on all paths; Stream proceeds opportunistically.
+- **Looping Construct**: All modes work; Merge and Converge use count check to avoid strictness on single inbound.
+- **Switch with Dangling Branches**: Stream ignores untaken; Merge waits for activated; Converge blocks on all.
+- **BPMN Alignment**: Stream ≈ XOR/OR-join (flexible); Merge ≈ AND-join for active paths; Converge ≈ strict AND-join; Race ≈ Event-based; Cascade ≈ parallel multi-instance.
## Consequences
diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
index 2ec34f8b6..0b4dbe0c2 100644
--- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
+++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
@@ -121,11 +121,16 @@ public static class ActivityExtensions
public static MergeMode GetMergeMode(this JsonObject activity)
{
- return activity.GetProperty("customProperties", "mergeMode") ?? MergeMode.None;
+ var value = activity.GetProperty("customProperties", "mergeMode");
+ // Treat MergeMode.None as equivalent to null (no merge mode set), defaulting to Stream
+ return value == null || value == MergeMode.None ? MergeMode.Stream : value.Value;
}
public static void SetMergeMode(this JsonObject activity, MergeMode? value)
{
+ // Treat MergeMode.None as equivalent to null (no merge mode set)
+ if (value == MergeMode.None)
+ value = null;
activity.SetProperty(JsonValue.Create(value), "customProperties", "mergeMode");
}
diff --git a/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs b/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
index c0a8944ab..20ca54aeb 100644
--- a/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
+++ b/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
@@ -2,28 +2,51 @@ namespace Elsa.Api.Client.Shared.Enums;
///
/// Specifies the strategy for handling multiple inbound execution paths in a workflow.
+/// Uses flow-based terminology to describe merge behavior.
///
public enum MergeMode
{
///
- /// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths.
- /// Suitable for flexible, unstructured merges where optional branches shouldn't block.
+ /// No merge mode set. Treated as if merge mode was not specified (null).
+ /// Provides backward compatibility for existing workflows.
+ /// Defaults to Stream behavior at runtime.
///
None,
///
- /// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
- /// Use for required synchronization points.
- ///
- Converge,
-
- ///
- /// Schedule on each arriving token, allowing multiple executions if supported.
+ /// Flows freely when possible, ignoring dead/untaken paths.
+ /// Opportunistic execution based on upstream completion.
+ /// Uses approximation that proceeds after all upstream sources complete.
+ /// Suitable for flexible, unstructured merges where optional branches shouldn't block.
///
Stream,
///
+ /// Merges only the activated/flowing inbound branches.
+ /// Waits for all branches that received tokens, ignoring unactivated ones.
+ /// Use for synchronization points where only taken paths matter (e.g., fork-joins with conditions).
+ ///
+ Merge,
+
+ ///
+ /// Converges all inbound paths, requiring every connection to execute.
+ /// Blocks until all branches complete, including unactivated ones.
+ /// Strictest mode - will block on dead/untaken paths.
+ /// Use when every single inbound path must execute before proceeding.
+ ///
+ Converge,
+
+ ///
+ /// Cascades execution for each arriving token independently.
+ /// Allows multiple concurrent executions (one per arriving token).
+ /// Use for streaming scenarios where each branch should trigger separate processing.
+ ///
+ Cascade,
+
+ ///
+ /// Races inbound branches, executing on first arrival and blocking others.
/// Schedule on the first arriving token, block or cancel others.
+ /// Use for competitive scenarios where only the first result matters.
///
Race
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs
index 69777be55..7d379389e 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs
@@ -37,7 +37,7 @@ public partial class Flowchart
switch (mergeMode)
{
- case MergeMode.Stream:
+ case MergeMode.Cascade:
case MergeMode.Race:
if (mergeMode == MergeMode.Race)
await flowContext.CancelInboundAncestorsAsync(targetActivity);
@@ -73,13 +73,15 @@ public partial class Flowchart
break;
- case MergeMode.Converge:
- // Strict WaitAll for multiple forwards; schedule on arrival for <=1 (e.g., loops).
- var inboundConnectionsConverge = flowGraph.GetForwardInboundConnections(targetActivity);
+ case MergeMode.Merge:
+ // Wait for tokens from all forward inbound connections.
+ // Unlike Converge, this ignores backward connections (loops).
+ // Schedule on arrival for <=1 forward inbound (e.g., loops, sequential).
+ var inboundConnectionsMerge = flowGraph.GetForwardInboundConnections(targetActivity);
- if (inboundConnectionsConverge.Count > 1)
+ if (inboundConnectionsMerge.Count > 1)
{
- var hasAllTokens = inboundConnectionsConverge.All(inbound =>
+ var hasAllTokens = inboundConnectionsMerge.All(inbound =>
tokens.Any(t =>
t is { Consumed: false, Blocked: false } &&
t.FromActivityId == inbound.Source.Activity.Id &&
@@ -98,11 +100,37 @@ public partial class Flowchart
break;
- case MergeMode.None:
+ case MergeMode.Converge:
+ // Strictest mode: Wait for tokens from ALL inbound connections (forward + backward).
+ // Requires every possible inbound path to execute before proceeding.
+ var allInboundConnectionsConverge = flowGraph.GetInboundConnections(targetActivity);
+
+ if (allInboundConnectionsConverge.Count > 1)
+ {
+ var hasAllTokens = allInboundConnectionsConverge.All(inbound =>
+ tokens.Any(t =>
+ t is { Consumed: false, Blocked: false } &&
+ t.FromActivityId == inbound.Source.Activity.Id &&
+ t.ToActivityId == targetActivity.Id &&
+ t.Outcome == inbound.Source.Port
+ )
+ );
+
+ if (hasAllTokens)
+ await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
+ }
+ else
+ {
+ await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
+ }
+
+ break;
+
+ case MergeMode.Stream:
default:
- // Approximation that proceeds on dead paths.
- var inboundConnectionsNone = flowGraph.GetForwardInboundConnections(targetActivity);
- var hasUnconsumed = inboundConnectionsNone.Any(inbound =>
+ // Flows freely - approximation that proceeds when upstream completes, ignoring dead paths.
+ var inboundConnectionsStream = flowGraph.GetForwardInboundConnections(targetActivity);
+ var hasUnconsumed = inboundConnectionsStream.Any(inbound =>
tokens.Any(t => !t.Consumed && !t.Blocked && t.ToActivityId == inbound.Source.Activity.Id)
);
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs
index e21f83c1e..8a202c489 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs
@@ -14,17 +14,21 @@ public static class ActivityExtensions
return null;
// Handle both string and enum values for backwards compatibility
- return value switch
+ var result = value switch
{
MergeMode mode => mode,
string str when Enum.TryParse(str, true, out var mode) => mode,
- _ => null
+ _ => (MergeMode?)null
};
+
+ // Treat MergeMode.None as equivalent to null (no merge mode set)
+ return result == MergeMode.None ? null : result;
}
public void SetMergeMode(MergeMode? value)
{
- if (value == null)
+ // Treat MergeMode.None as equivalent to null (no merge mode set)
+ if (value == null || value == MergeMode.None)
activity.CustomProperties.Remove("mergeMode");
else
activity.CustomProperties["mergeMode"] = value.ToString()!;
@@ -36,7 +40,7 @@ public static class ActivityExtensions
{
return activity.GetMergeMode();
}
-
+
// Handle deprecated FlowJoin activity by evaluating its JoinMode property and mapping it to the appropriate MergeMode equivalent.
var joinActivityExecutionContext = await context.WorkflowExecutionContext.CreateActivityExecutionContextAsync(activity);
var joinMode = await joinActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode);
@@ -44,7 +48,7 @@ public static class ActivityExtensions
return joinMode switch
{
FlowJoinMode.WaitAny => MergeMode.Race,
- _ => MergeMode.Converge
+ _ => MergeMode.Merge // WaitAll maps to Merge (wait for all activated branches)
};
}
}
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs
index ad3ecbcde..8d84fbc15 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs
@@ -2,28 +2,52 @@ namespace Elsa.Workflows.Activities.Flowchart.Models;
///
/// Specifies the strategy for handling multiple inbound execution paths in a workflow.
+/// Uses flow-based terminology to describe merge behavior.
///
public enum MergeMode
{
///
- /// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths.
- /// Suitable for flexible, unstructured merges where optional branches shouldn't block.
+ /// No merge mode set. Treated as if merge mode was not specified (null).
+ /// Provides backward compatibility for existing workflows.
+ /// Defaults to Stream behavior at runtime.
///
None,
///
- /// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
- /// Use for required synchronization points.
- ///
- Converge,
-
- ///
- /// Schedule on each arriving token, allowing multiple executions if supported.
+ /// Flows freely when possible, ignoring dead/untaken paths.
+ /// Opportunistic execution based on upstream completion.
+ /// Uses approximation that proceeds after all upstream sources complete.
+ /// Suitable for flexible, unstructured merges where optional branches shouldn't block.
///
Stream,
///
+ /// Waits for all forward (acyclic) inbound connections before proceeding.
+ /// Unlike Converge, this ignores backward connections (loops).
+ /// Will block on dead/untaken paths if they are forward connections.
+ /// Use for synchronization points in structured fork-join patterns.
+ ///
+ Merge,
+
+ ///
+ /// Converges all inbound paths, requiring every connection to execute.
+ /// Blocks until all branches complete, including unactivated ones.
+ /// Strictest mode - will block on dead/untaken paths.
+ /// Use when every single inbound path must execute before proceeding.
+ ///
+ Converge,
+
+ ///
+ /// Cascades execution for each arriving token independently.
+ /// Allows multiple concurrent executions (one per arriving token).
+ /// Use for streaming scenarios where each branch should trigger separate processing.
+ ///
+ Cascade,
+
+ ///
+ /// Races inbound branches, executing on first arrival and blocking others.
/// Schedule on the first arriving token, block or cancel others.
+ /// Use for competitive scenarios where only the first result matters.
///
Race
}
\ No newline at end of file
diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs
index d38200035..0623897de 100644
--- a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs
+++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs
@@ -1,5 +1,4 @@
using Elsa.Testing.Shared;
-using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Models;
diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs
index d1fadbf85..b62d27882 100644
--- a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs
+++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs
@@ -1,5 +1,4 @@
using Elsa.Testing.Shared;
-using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Extensions;
@@ -239,7 +238,7 @@ public class FlowchartTokenBasedTests : IDisposable
var branch1 = new WriteLine("Branch1");
var branch2 = new WriteLine("Branch2");
var noneMode = new WriteLine("NoneMode");
- noneMode.SetMergeMode(MergeMode.None);
+ noneMode.SetMergeMode(null);
var flowchart = new Flowchart
{
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs
index f3d678175..34ad36849 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs
@@ -13,25 +13,31 @@ public class ForkDecisionJoinTests
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
}
- [Fact(DisplayName = "The implicit join configured with None merge mode should execute.")]
- public async Task ImplicitJoinNoneShouldExecute()
+ [Fact(DisplayName = "The implicit join configured with Stream merge mode should execute.")]
+ public async Task ImplicitJoinStreamShouldExecute()
{
await RunAndAssert("fork-decision-join-none.json", ["A", "C"]);
}
-
- [Fact(DisplayName = "The implicit join configured with Converge merge mode should not execute.")]
- public async Task ImplicitJoinConvergeShouldNotExecute()
+
+ [Fact(DisplayName = "The implicit join configured with Merge mode should not execute (waits for activated branches only).")]
+ public async Task ImplicitJoinMergeShouldNotExecute()
{
await RunAndAssert("fork-decision-join-converge.json", ["A"]);
}
-
+
+ [Fact(DisplayName = "The implicit join configured with Converge mode should block (strictest - requires ALL inbound connections).")]
+ public async Task ImplicitJoinConvergeShouldBlock()
+ {
+ await RunAndAssert("fork-decision-join-converge-strict.json", ["A"]);
+ }
+
[Fact(DisplayName = "The explicit join configured with WaitAll join mode should block.")]
public async Task ExplicitJoinWaitAllShouldBlock()
{
await RunAndAssert("fork-decision-join-waitall.json", ["A", "C", "B", "D"]);
}
- [Fact(DisplayName = "An implicit join from the True and False branches should execute because the join mode is None and by default, all active branches are joined.")]
+ [Fact(DisplayName = "An implicit join from the True and False branches should execute because the join mode is Stream and by default, all active branches are joined.")]
public async Task ImplicitJoinFromBranchesShouldExecute()
{
// Populate registries.
@@ -42,7 +48,7 @@ public class ForkDecisionJoinTests
// Execute.
var workflowState = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);
-
+
// Assert.
Assert.Equal(WorkflowStatus.Finished, workflowState.Status);
}
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge-strict.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge-strict.json
new file mode 100644
index 000000000..40ca9c9f2
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge-strict.json
@@ -0,0 +1,184 @@
+{
+ "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
+ "id": "strict-converge-test",
+ "definitionId": "strict-converge-001",
+ "name": "Fork-Decision-Converge-Strict",
+ "description": "Tests the strictest Converge mode that requires ALL inbound connections to execute",
+ "createdAt": "2025-10-20T00:00:00.000000+00:00",
+ "version": 1,
+ "toolVersion": "3.6.0.0",
+ "variables": [],
+ "inputs": [],
+ "outputs": [],
+ "outcomes": [],
+ "customProperties": {},
+ "isReadonly": false,
+ "isSystem": false,
+ "isLatest": true,
+ "isPublished": true,
+ "options": {
+ "autoUpdateConsumingWorkflows": false
+ },
+ "root": {
+ "id": "flowchart-root",
+ "nodeId": "Workflow1:flowchart-root",
+ "name": "Flowchart1",
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "customProperties": {
+ "notFoundConnections": [],
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {},
+ "activities": [
+ {
+ "condition": {
+ "typeName": "Boolean",
+ "expression": {
+ "type": "JavaScript",
+ "value": "return false;"
+ }
+ },
+ "id": "decision-1",
+ "nodeId": "Workflow1:flowchart-root:decision-1",
+ "name": "FlowDecision1",
+ "type": "Elsa.FlowDecision",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {}
+ },
+ {
+ "id": "start-1",
+ "nodeId": "Workflow1:flowchart-root:start-1",
+ "name": "Start1",
+ "type": "Elsa.Start",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {}
+ },
+ {
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "A"
+ }
+ },
+ "id": "writeline-a",
+ "nodeId": "Workflow1:flowchart-root:writeline-a",
+ "name": "WriteLine1",
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {}
+ },
+ {
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "B"
+ }
+ },
+ "id": "writeline-b",
+ "nodeId": "Workflow1:flowchart-root:writeline-b",
+ "name": "WriteLine2",
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {}
+ },
+ {
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "C"
+ }
+ },
+ "id": "writeline-c",
+ "nodeId": "Workflow1:flowchart-root:writeline-c",
+ "name": "WriteLine3",
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false,
+ "mergeMode": "Converge"
+ },
+ "metadata": {}
+ }
+ ],
+ "variables": [],
+ "connections": [
+ {
+ "source": {
+ "activity": "writeline-a",
+ "port": "Done"
+ },
+ "target": {
+ "activity": "writeline-c",
+ "port": "In"
+ },
+ "vertices": []
+ },
+ {
+ "source": {
+ "activity": "writeline-b",
+ "port": "Done"
+ },
+ "target": {
+ "activity": "writeline-c",
+ "port": "In"
+ },
+ "vertices": []
+ },
+ {
+ "source": {
+ "activity": "start-1",
+ "port": "Done"
+ },
+ "target": {
+ "activity": "writeline-a",
+ "port": "In"
+ },
+ "vertices": []
+ },
+ {
+ "source": {
+ "activity": "start-1",
+ "port": "Done"
+ },
+ "target": {
+ "activity": "decision-1",
+ "port": "In"
+ },
+ "vertices": []
+ },
+ {
+ "source": {
+ "activity": "decision-1",
+ "port": "True"
+ },
+ "target": {
+ "activity": "writeline-b",
+ "port": "In"
+ },
+ "vertices": []
+ }
+ ]
+ }
+}
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json
index 75dc547a4..8bc40e337 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json
@@ -174,7 +174,7 @@
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false,
- "mergeMode": "Converge"
+ "mergeMode": "Merge"
},
"metadata": {
"designer": {
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json
index 9aee4d16d..ddb3db562 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json
@@ -174,7 +174,7 @@
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false,
- "mergeMode": "None"
+ "mergeMode": "Stream"
},
"metadata": {
"designer": {