diff --git a/Elsa.sln b/Elsa.sln
index 499a256b1..1a2bd9126 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -224,6 +224,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C
doc\adr\toc.md = doc\adr\toc.md
doc\adr\0005-activity-execution-snapshots.md = doc\adr\0004-activity-execution-snapshots.md
doc\adr\0006-tenant-deleted-event.md = doc\adr\0005-tenant-deleted-event.md
+ doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md = doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A705-2E31-4012-964A-83963DCDB384}"
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
new file mode 100644
index 000000000..b1143eca0
--- /dev/null
+++ b/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
@@ -0,0 +1,170 @@
+# 6. Adoption of Explicit Merge Modes for Flowchart Joins
+
+Date: 2025-09-30
+
+## Status
+
+Accepted
+
+## Context
+
+The Flowchart activity serves as a container for orchestrating workflows through activities connected via directed edges, using a token-based model for control flow. Initially, the execution logic relied on a combination of counter-based and token-based approaches, with implicit handling for merging paths (joins). However, this led to inconsistencies:
+
+- **Premature Scheduling in Forks**: In conditional forks with converges, untaken branches (e.g., false decision outcomes) allowed downstream activities to execute unexpectedly, violating expected blocking behavior.
+- **Stalling in Loops**: Strict token checks for all inbounds broke loops by consuming entry tokens and failing to reschedule on backward connections.
+- **Inconsistent Merges in Complex Flows**: In workflows with switches and multiple branches (e.g., MatchAny modes), dead paths (untaken defaults) caused hangs under strict rules but proceeded under approximations, leading to conflicting expectations.
+
+The root issue was the lack of explicit, configurable semantics for joins, relying instead on heuristics (e.g., inbound connection count >1). This made behavior opaque and error-prone, especially in unstructured flowcharts. Inspired by BPMN gateway semantics (e.g., AND-join for strict sync, OR-join for partial), we needed a clearer model to balance safety (blocking on required paths) and flexibility (proceeding on dead paths).
+
+## Decision
+
+We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBasedLogicAsync`) to use an explicit `MergeMode` enum on activities. This eliminates null/default fallbacks, making behaviors self-documenting and configurable via activity properties.
+
+- **MergeMode Enum Definition** (in `MergeMode.cs`):
+ ```csharp
+ namespace Elsa.Workflows.Activities.Flowchart.Models;
+
+ 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.
+ ///
+ Stream,
+
+ ///
+ /// Schedule on the first arriving token, block or cancel others.
+ ///
+ Race
+ }
+ ```
+
+- **Key Changes in Flowchart Execution**:
+ - **Token Emission and Consumption**: On activity completion, emit tokens only for active outcomes (matching connections). Consume inbound tokens post-execution.
+ - **Scheduling Logic**: For each outbound connection, evaluate the target's `MergeMode` (via `GetMergeModeAsync`). Handle each mode explicitly in a switch statement.
+ - **Graph Reliance**: Use `FlowGraph` for forward inbound connections (acyclic); backward connections (e.g., loops) are handled naturally without inflating counts.
+ - **Dead Path Handling**: Varies by mode (strict blocking in Converge; approximation in None).
+ - **Loop Support**: Converge mode checks inbound count >1 to schedule immediately for sequentials/loops (<=1 forwards).
+ - **Cancellation and Purging**: Retained for races and overall cleanup.
+
+- **Implementation Snippet** (from `Flowchart` partial class; full code in PR):
+ ```csharp
+ switch (mergeMode)
+ {
+ case MergeMode.Stream:
+ case MergeMode.Race:
+ // Existing logic: Schedule on arrival, block others for Race.
+ // ...
+ break;
+
+ case MergeMode.Converge:
+ // Strict check: Wait for all forward inbounds if >1; else schedule immediately.
+ var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
+ if (inboundConnections.Count > 1)
+ {
+ var hasAllTokens = inboundConnections.All(inbound => /* token check */);
+ if (hasAllTokens) await flowContext.ScheduleActivityAsync(...);
+ }
+ else
+ {
+ await flowContext.ScheduleActivityAsync(...);
+ }
+ break;
+
+ case MergeMode.None:
+ default:
+ // Approximation: Schedule if no unconsumed tokens to inbound sources.
+ var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
+ var hasUnconsumed = inboundConnections.Any(inbound => /* source token check */);
+ if (!hasUnconsumed) await flowContext.ScheduleActivityAsync(...);
+ break;
+ }
+ ```
+
+### Functional Overview
+Flowchart execution starts with scheduling the root/start activity. As activities complete:
+1. Emit tokens for matching outbound connections.
+2. Consume the activity's inbound tokens.
+3. For each emitted token's target:
+ - Fetch its `MergeMode`.
+ - Apply mode-specific logic to decide scheduling.
+4. Purge consumed tokens and complete the flowchart if no pending work.
+
+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:
+
+- **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").
+ - **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).
+ - **Loops**: Schedules on loop-back tokens (backward ignored in forward inbounds).
+ - **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.
+ - **Scenarios**:
+ - **Conditional Forks**: Blocks downstream if e.g., decision returns false and subsequent activities are connected to the true branch.
+ - **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.
+
+- **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).
+ - **Scenarios**:
+ - **Forks**: Executes target per branch.
+ - **Loops**: Executes per iteration.
+ - **Dead Paths**: Ignores; only active tokens trigger.
+ - **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).
+ - **Scenarios**:
+ - **Forks**: Only first branch proceeds.
+ - **Loops**: May race iterations if concurrent.
+ - **Dead Paths**: First active wins; others blocked.
+ - **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.
+
+## Consequences
+
+- **Positive**:
+ - Clearer semantics: Explicit modes reduce bugs and improve workflow design.
+ - Flexibility: Users choose behavior per activity.
+ - Reliability: Fixes identified flaws across forks, loops, and complexes.
+ - Extensibility: Enum can grow (e.g., for BPMN Complex).
+
+- **Negative**:
+ - Complexity: More modes mean more testing; document well.
+ - Performance: Token checks add minor overhead (optimize with caching).
+
+## Alternatives Considered
+
+- **Heuristics-Only**: Relied on inbound count/graph structure—too brittle, led to conflicts.
+- **Full BPMN Gateways**: Dedicated activities per type (e.g., ParallelGateway)—overkill for Elsa's simplicity; would require major refactor.
+- **Dead Path Propagation**: Emit blocked tokens on untaken paths—adds complexity; deferred for future if needed (e.g., for OR-join).
+- **Counter-Based Fallback**: Retained old logic—deprecated for token purity.
\ 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 deae51926..2ec34f8b6 100644
--- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
+++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
@@ -121,7 +121,7 @@ public static class ActivityExtensions
public static MergeMode GetMergeMode(this JsonObject activity)
{
- return activity.GetProperty("customProperties", "mergeMode") ?? MergeMode.Converge;
+ return activity.GetProperty("customProperties", "mergeMode") ?? MergeMode.None;
}
public static void SetMergeMode(this JsonObject activity, MergeMode? value)
diff --git a/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs b/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
index d2f846440..c0a8944ab 100644
--- a/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
+++ b/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
@@ -6,17 +6,24 @@ namespace Elsa.Api.Client.Shared.Enums;
public enum MergeMode
{
///
- /// Wait for all inbound paths before proceeding.
+ /// 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,
-
+
///
- /// Proceed when any one inbound path completes; cancel all others.
+ /// Schedule on each arriving token, allowing multiple executions if supported.
///
- Race,
-
+ Stream,
+
///
- /// Proceed when any one inbound path completes; do not cancel others.
+ /// Schedule on the first arriving token, block or cancel others.
///
- Stream
+ 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 bfba1dda7..69777be55 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
@@ -15,7 +15,7 @@ public partial class Flowchart
var completedActivity = ctx.ChildContext.Activity;
var flowGraph = flowContext.GetFlowGraph();
- // Emit tokens.
+ // Emit tokens for active outcomes.
var outcomes = (ctx.Result as Outcomes ?? Outcomes.Default).Names;
var outboundConnections = flowGraph.GetOutboundConnections(completedActivity);
var activeOutboundConnections = outboundConnections.Where(x => outcomes.Contains(x.Source.Port)).Distinct().ToList();
@@ -24,70 +24,102 @@ public partial class Flowchart
foreach (var connection in activeOutboundConnections)
tokens.Add(Token.Create(connection.Source.Activity, connection.Target.Activity, connection.Source.Port));
- // Consume tokens.
+ // Consume inbound tokens to the completed activity.
var inboundTokens = tokens.Where(t => t.ToActivityId == completedActivity.Id && t is { Consumed: false, Blocked: false }).ToList();
foreach (var t in inboundTokens)
t.Consume();
- // Schedule next activities.
+ // Schedule next activities based on merge modes.
foreach (var connection in activeOutboundConnections)
{
var targetActivity = connection.Target.Activity;
var mergeMode = await targetActivity.GetMergeModeAsync(ctx.ChildContext);
- if (mergeMode is MergeMode.Stream or MergeMode.Race)
+ switch (mergeMode)
{
- if (mergeMode == MergeMode.Race)
- await flowContext.CancelInboundAncestorsAsync(targetActivity);
+ case MergeMode.Stream:
+ case MergeMode.Race:
+ if (mergeMode == MergeMode.Race)
+ await flowContext.CancelInboundAncestorsAsync(targetActivity);
- // Check if there is any blocking token preventing the activity from being scheduled.
- var existingBlockedToken = tokens.FirstOrDefault(t => t.ToActivityId == targetActivity.Id && t.FromActivityId == connection.Source.Activity.Id && t.Outcome == connection.Source.Port && t.Blocked);
+ // Check for existing blocked token on this specific connection.
+ var existingBlockedToken = tokens.FirstOrDefault(t =>
+ t.ToActivityId == targetActivity.Id &&
+ t.FromActivityId == connection.Source.Activity.Id &&
+ t.Outcome == connection.Source.Port &&
+ t.Blocked);
- if (existingBlockedToken == null)
- {
- // Schedule the target activity.
- await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
-
- // And block other inbound connections.
- var otherInboundConnections = flowGraph.GetForwardInboundConnections(targetActivity).Where(x => x.Source.Activity != completedActivity).ToList();
-
- foreach (var inboundConnection in otherInboundConnections)
+ if (existingBlockedToken == null)
{
- var blockedToken = Token.Create(inboundConnection.Source.Activity, inboundConnection.Target.Activity, inboundConnection.Source.Port).Block();
- tokens.Add(blockedToken);
- }
- }
- else
- {
- // Consume the block.
- existingBlockedToken.Consume();
- }
- }
- else
- {
- // Wait for all inbound tokens to be consumed before scheduling the target activity.
- var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
- var hasUnconsumed = inboundConnections.Any(inbound =>
- tokens.Any(t => t is { Consumed: false, Blocked: false } && t.ToActivityId == inbound.Source.Activity.Id)
- );
+ // Schedule the target.
+ await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
- if (!hasUnconsumed)
- {
- await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
- }
+ // Block other inbound connections (adjust per mode if needed).
+ var otherInboundConnections = flowGraph.GetForwardInboundConnections(targetActivity)
+ .Where(x => x.Source.Activity != completedActivity)
+ .ToList();
+
+ foreach (var inboundConnection in otherInboundConnections)
+ {
+ var blockedToken = Token.Create(inboundConnection.Source.Activity, inboundConnection.Target.Activity, inboundConnection.Source.Port).Block();
+ tokens.Add(blockedToken);
+ }
+ }
+ else
+ {
+ // Consume the block without scheduling.
+ existingBlockedToken.Consume();
+ }
+
+ break;
+
+ case MergeMode.Converge:
+ // Strict WaitAll for multiple forwards; schedule on arrival for <=1 (e.g., loops).
+ var inboundConnectionsConverge = flowGraph.GetForwardInboundConnections(targetActivity);
+
+ if (inboundConnectionsConverge.Count > 1)
+ {
+ var hasAllTokens = inboundConnectionsConverge.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.None:
+ default:
+ // Approximation that proceeds on dead paths.
+ var inboundConnectionsNone = flowGraph.GetForwardInboundConnections(targetActivity);
+ var hasUnconsumed = inboundConnectionsNone.Any(inbound =>
+ tokens.Any(t => !t.Consumed && !t.Blocked && t.ToActivityId == inbound.Source.Activity.Id)
+ );
+
+ if (!hasUnconsumed)
+ await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
+ break;
}
}
- // Complete flow if done.
- var hasPendingWork = flowContext.HasPendingWork();
-
- if (!hasPendingWork)
+ // Complete flowchart if no pending work.
+ if (!flowContext.HasPendingWork())
{
tokens.Clear();
await flowContext.CompleteActivityAsync();
}
- // Purge tokens.
+ // Purge consumed tokens for the completed activity.
tokens.RemoveWhere(t => t.ToActivityId == completedActivity.Id && t.Consumed);
}
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 28fdf0a03..ad3ecbcde 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs
@@ -6,17 +6,24 @@ namespace Elsa.Workflows.Activities.Flowchart.Models;
public enum MergeMode
{
///
- /// Wait for all inbound paths before proceeding.
+ /// 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,
-
+
///
- /// Proceed when any one inbound path completes; cancel all others.
+ /// Schedule on each arriving token, allowing multiple executions if supported.
///
- Race,
-
+ Stream,
+
///
- /// Proceed when any one inbound path completes; do not cancel others.
+ /// Schedule on the first arriving token, block or cancel others.
///
- Stream
+ Race
}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
index eb322b490..79007a43c 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
@@ -23,5 +23,14 @@
Always
+
+ Always
+
+
+ Always
+
+
+ Always
+
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs
index fc3574c33..ff3639ac3 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs
@@ -47,8 +47,8 @@ public class FlowchartNextActivityTests
var writeLineDecision = new FlowSwitch()
{
Cases = {
- new FlowSwitchCase("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
- new FlowSwitchCase("LessThanOne", new Expression("JavaScript", "getVariable('LoopCount') < 1")),
+ new("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
+ new("LessThanOne", new Expression("JavaScript", "getVariable('LoopCount') < 1")),
},
Mode = new(SwitchMode.MatchAny)
};
@@ -58,13 +58,13 @@ public class FlowchartNextActivityTests
var incrementLoop = new SetVariable()
{
Variable = loopVariable,
- Value = new Models.Input