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(new Expression("JavaScript", "getVariable('LoopCount') + 1")) + Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1")) }; var loopbackDecision = new FlowSwitch() { Cases = { - new FlowSwitchCase("EqualOne", new Expression("JavaScript", "getVariable('LoopCount') == 1")), - new FlowSwitchCase("LessThanFour", new Expression("JavaScript", "getVariable('LoopCount') < 4")), + new("EqualOne", new Expression("JavaScript", "getVariable('LoopCount') == 1")), + new("LessThanFour", new Expression("JavaScript", "getVariable('LoopCount') < 4")), }, Mode = new(SwitchMode.MatchFirst) }; @@ -99,19 +99,19 @@ public class FlowchartNextActivityTests { new(start, writeLineDecision), new(dangling, writeLineDecision), - new(new Endpoint(writeLineDecision, "LessThanThree"), new Endpoint(a)), - new(new Endpoint(writeLineDecision, "LessThanThree"), new Endpoint(b)), - new(new Endpoint(writeLineDecision, "LessThanOne"), new Endpoint(c)), - new(new Endpoint(writeLineDecision, "Default"), new Endpoint(incrementLoop)), + new(new(writeLineDecision, "LessThanThree"), new Endpoint(a)), + new(new(writeLineDecision, "LessThanThree"), new Endpoint(b)), + new(new(writeLineDecision, "LessThanOne"), new Endpoint(c)), + new(new(writeLineDecision, "Default"), new Endpoint(incrementLoop)), new(a, incrementLoop), new(b, incrementLoop), new(c, incrementLoop), new(incrementLoop, loopbackDecision), - new(new Endpoint(loopbackDecision, "EqualOne"), new Endpoint(d)), + new(new(loopbackDecision, "EqualOne"), new Endpoint(d)), new(d, incrementLoop), - new(new Endpoint(loopbackDecision, "LessThanFour"), new Endpoint(e)), + new(new(loopbackDecision, "LessThanFour"), new Endpoint(e)), new(e, writeLineDecision), - new(new Endpoint(loopbackDecision, "Default"), new Endpoint(f)), + new(new(loopbackDecision, "Default"), new Endpoint(f)), new(f, end), } }; @@ -193,18 +193,17 @@ public class FlowchartNextActivityTests var incrementLoop = new SetVariable() { Variable = loopVariable, - Value = new Models.Input(new Expression("JavaScript", "getVariable('LoopCount') + 1")) + Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1")) }; var loopbackDecision = new FlowSwitch() { Cases = { - new FlowSwitchCase("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")), + new("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")), }, Mode = new(SwitchMode.MatchFirst) }; var end = new End(); - - + workflowBuilder.Root = new Flowchart { Variables = @@ -234,8 +233,8 @@ public class FlowchartNextActivityTests new(d, join), new(join, incrementLoop), new(incrementLoop,loopbackDecision), - new(new Endpoint(loopbackDecision, "LessThanThree"), new Endpoint(a)), - new(new Endpoint(loopbackDecision, "Default"), new Endpoint(end)), + new(new(loopbackDecision, "LessThanThree"), new Endpoint(a)), + new(new(loopbackDecision, "Default"), new Endpoint(end)), } }; }); @@ -245,7 +244,6 @@ public class FlowchartNextActivityTests var lines = _capturingTextWriter.Lines.ToList(); Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); Assert.Equal(new[] { "A", "B", "C", "D", "A", "B", "C", "D", "A", "B", "C", "D"}, lines); - } [Theory(DisplayName = "Flowchart with a Join activity executed multiple times, bug 6479")] @@ -261,7 +259,7 @@ public class FlowchartNextActivityTests var loopbackSwitch = new FlowSwitch() { Cases = { - new FlowSwitchCase("DoLoopback", new Expression("JavaScript", "getVariable('LoopCount') < 3")), + new("DoLoopback", new Expression("JavaScript", "getVariable('LoopCount') < 3")), }, Mode = new(SwitchMode.MatchFirst) }; @@ -269,7 +267,7 @@ public class FlowchartNextActivityTests var incrementLoop = new SetVariable() { Variable = loopVariable, - Value = new Models.Input(new Expression("JavaScript", "getVariable('LoopCount') + 1")) + Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1")) }; var join = new FlowJoin() { @@ -277,8 +275,7 @@ public class FlowchartNextActivityTests }; var b = new WriteLine("B"); var end = new End(); - - + workflowBuilder.Root = new Flowchart { Variables = @@ -298,9 +295,9 @@ public class FlowchartNextActivityTests Connections = { new(start, loopbackSwitch), - new(new Endpoint(loopbackSwitch, "DoLoopback"), new Endpoint(a)), - new(new Endpoint(loopbackSwitch, "DoLoopback"), new Endpoint(incrementLoop)), - new(new Endpoint(loopbackSwitch, "Default"), new Endpoint(b)), + new(new(loopbackSwitch, "DoLoopback"), new Endpoint(a)), + new(new(loopbackSwitch, "DoLoopback"), new Endpoint(incrementLoop)), + new(new(loopbackSwitch, "Default"), new Endpoint(b)), new(a, join), new(incrementLoop, join), new(join, loopbackSwitch), diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/BraidedWorkflowTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/BraidedWorkflowTests.cs similarity index 91% rename from test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/BraidedWorkflowTests.cs rename to test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/BraidedWorkflowTests.cs index ce182b215..5c77b115e 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/BraidedWorkflowTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/BraidedWorkflowTests.cs @@ -1,9 +1,9 @@ using Elsa.Testing.Shared; -using Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins.Workflows; +using Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; -namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins; +namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors; public class BraidedWorkflowTests { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs new file mode 100644 index 000000000..c3aca5a2a --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs @@ -0,0 +1,49 @@ +using Elsa.Testing.Shared; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors; + +public class ForkDecisionJoinTests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _services; + + public ForkDecisionJoinTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); + } + + [Fact(DisplayName = "The implicit join configured with None merge mode should execute.")] + public async Task ImplicitJoinNoneShouldExecute() + { + 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() + { + await RunAndAssert("fork-decision-join-converge.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"]); + } + + private async Task RunAndAssert(string workflowFileName, string[] expectedLines) + { + // Populate registries. + await _services.PopulateRegistriesAsync(); + + // Import workflow. + var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/{workflowFileName}"); + + // Execute. + await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId); + + // Assert. + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Equal(expectedLines, lines); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/ImplicitLoopWorkflowTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ImplicitLoopWorkflowTests.cs similarity index 90% rename from test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/ImplicitLoopWorkflowTests.cs rename to test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ImplicitLoopWorkflowTests.cs index 403cac33f..4d6e5ebbb 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/ImplicitLoopWorkflowTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ImplicitLoopWorkflowTests.cs @@ -1,9 +1,9 @@ using Elsa.Testing.Shared; -using Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins.Workflows; +using Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; -namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins; +namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors; public class ImplicitWorkflowTests { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/JoinRunsOnceTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs similarity index 86% rename from test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/JoinRunsOnceTests.cs rename to test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs index 1e555e73a..b1d4ebdb7 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/JoinRunsOnceTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs @@ -5,7 +5,7 @@ using Elsa.Workflows.Runtime.Filters; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; -namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins; +namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors; public class JoinRunsOnceTests { @@ -24,13 +24,13 @@ public class JoinRunsOnceTests await _services.PopulateRegistriesAsync(); // Import workflow. - var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/ImplicitJoins/Workflows/join.json"); + var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/join.json"); // Execute. var state = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId); // Assert. - var journal = await _services.GetRequiredService().FindManyAsync(new WorkflowExecutionLogRecordFilter + var journal = await _services.GetRequiredService().FindManyAsync(new() { WorkflowInstanceId = state.Id, ActivityId = "802725996be1b582", diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/ParallelJoinCompletesTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ParallelJoinCompletesTests.cs similarity index 88% rename from test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/ParallelJoinCompletesTests.cs rename to test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ParallelJoinCompletesTests.cs index cefc1f76b..22c5d8a71 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/ParallelJoinCompletesTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ParallelJoinCompletesTests.cs @@ -1,11 +1,10 @@ using Elsa.Common.Models; using Elsa.Testing.Shared; using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Filters; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; -namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins; +namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors; public class ParallelJoinCompletesTests { @@ -24,7 +23,7 @@ public class ParallelJoinCompletesTests await _services.PopulateRegistriesAsync(); // Import workflow. - var workflowDefinition = await _services.ImportWorkflowDefinitionAsync("Scenarios/ImplicitJoins/Workflows/parallel-join.json"); + var workflowDefinition = await _services.ImportWorkflowDefinitionAsync("Scenarios/JoinBehaviors/Workflows/parallel-join.json"); // Execute. var state = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId); diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/BraidedWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs similarity index 65% rename from test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/BraidedWorkflow.cs rename to test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs index 7adbc4a34..872283064 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/BraidedWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs @@ -2,7 +2,7 @@ using Elsa.Workflows.Activities; using Elsa.Workflows.Activities.Flowchart.Activities; using Elsa.Workflows.Activities.Flowchart.Models; -namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins.Workflows; +namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows; public class BraidedWorkflow : WorkflowBase { @@ -33,18 +33,18 @@ public class BraidedWorkflow : WorkflowBase Connections = { - new Connection(writeLine1, writeLine2), - new Connection(writeLine1, writeLine3), + new(writeLine1, writeLine2), + new(writeLine1, writeLine3), - new Connection(writeLine2, writeLine4), - new Connection(writeLine2, writeLine5), + new(writeLine2, writeLine4), + new(writeLine2, writeLine5), - new Connection(writeLine3, writeLine5), - new Connection(writeLine3, writeLine6), + new(writeLine3, writeLine5), + new(writeLine3, writeLine6), - new Connection(writeLine4, writeLine7), - new Connection(writeLine5, writeLine7), - new Connection(writeLine6, writeLine7), + new(writeLine4, writeLine7), + new(writeLine5, writeLine7), + new(writeLine6, writeLine7), } }; } diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/ImplicitLoopWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/ImplicitLoopWorkflow.cs similarity index 69% rename from test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/ImplicitLoopWorkflow.cs rename to test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/ImplicitLoopWorkflow.cs index f34beffab..c6ada7dce 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/ImplicitLoopWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/ImplicitLoopWorkflow.cs @@ -2,7 +2,7 @@ using Elsa.Workflows.Activities; using Elsa.Workflows.Activities.Flowchart.Activities; using Elsa.Workflows.Activities.Flowchart.Models; -namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins.Workflows; +namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows; public class ImplicitLoopWorkflow : WorkflowBase { @@ -30,11 +30,11 @@ public class ImplicitLoopWorkflow : WorkflowBase Connections = { - new Connection(start, incrementCounter), - new Connection(incrementCounter, counterGreaterThanOne), - new Connection(new Endpoint(counterGreaterThanOne, "False"), new Endpoint(retry)), - new Connection(new Endpoint(counterGreaterThanOne, "True"), new Endpoint(end)), - new Connection(retry, incrementCounter), + new(start, incrementCounter), + new(incrementCounter, counterGreaterThanOne), + new(new(counterGreaterThanOne, "False"), new Endpoint(retry)), + new(new(counterGreaterThanOne, "True"), new Endpoint(end)), + new(retry, incrementCounter), } }; } 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 new file mode 100644 index 000000000..75dc547a4 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json @@ -0,0 +1,253 @@ +{ + "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json", + "id": "b60374027329240a", + "definitionId": "3d3412c458178fff", + "name": "Fork-issues", + "description": "show case", + "createdAt": "2025-09-30T16:56:12.420213\u002B00:00", + "version": 11, + "toolVersion": "3.6.0.0", + "variables": [ + { + "id": "93b1a1cb9221a635", + "name": "myNumber", + "typeName": "Int32", + "isArray": false, + "value": "0", + "storageDriverTypeName": "Elsa.Workflows.WorkflowInstanceStorageDriver, Elsa.Workflows.Core" + } + ], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": { + "VariableTestValues": {} + }, + "isReadonly": false, + "isSystem": false, + "isLatest": true, + "isPublished": true, + "options": { + "autoUpdateConsumingWorkflows": false + }, + "root": { + "id": "8d3ecf38f4425d86", + "nodeId": "Workflow1:8d3ecf38f4425d86", + "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": "35c0f40b240666c6", + "nodeId": "Workflow1:8d3ecf38f4425d86:35c0f40b240666c6", + "name": "FlowDecision1", + "type": "Elsa.FlowDecision", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 337.5703125, + "y": 161.96484375 + }, + "size": { + "width": 149.84375, + "height": 67.9765625 + } + } + } + }, + { + "id": "342cf539286a991d", + "nodeId": "Workflow1:8d3ecf38f4425d86:342cf539286a991d", + "name": "Start1", + "type": "Elsa.Start", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 96.32421875, + "y": 48.98828125 + }, + "size": { + "width": 122.6484375, + "height": 67.9765625 + } + } + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "A" + } + }, + "id": "79e963ee9889c30a", + "nodeId": "Workflow1:8d3ecf38f4425d86:79e963ee9889c30a", + "name": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 609.34375, + "y": 48.98828125 + }, + "size": { + "width": 183.703125, + "height": 67.9765625 + } + }, + "displayText": "A" + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "B" + } + }, + "id": "af2cc31d1cef7a90", + "nodeId": "Workflow1:8d3ecf38f4425d86:af2cc31d1cef7a90", + "name": "WriteLine9", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 609.34375, + "y": 161.96484375 + }, + "size": { + "width": 159.6171875, + "height": 67.9765625 + } + }, + "displayText": "B" + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "C" + } + }, + "id": "768b5a7e58c3ecf8", + "nodeId": "Workflow1:8d3ecf38f4425d86:768b5a7e58c3ecf8", + "name": "WriteLine4", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false, + "mergeMode": "Converge" + }, + "metadata": { + "designer": { + "position": { + "x": 885.923828125, + "y": 48.98828125 + }, + "size": { + "width": 159.6171875, + "height": 67.9765625 + } + }, + "displayText": "C" + } + } + ], + "variables": [], + "connections": [ + { + "source": { + "activity": "79e963ee9889c30a", + "port": "Done" + }, + "target": { + "activity": "768b5a7e58c3ecf8", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "af2cc31d1cef7a90", + "port": "Done" + }, + "target": { + "activity": "768b5a7e58c3ecf8", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "342cf539286a991d", + "port": "Done" + }, + "target": { + "activity": "79e963ee9889c30a", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "342cf539286a991d", + "port": "Done" + }, + "target": { + "activity": "35c0f40b240666c6", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "35c0f40b240666c6", + "port": "True" + }, + "target": { + "activity": "af2cc31d1cef7a90", + "port": "In" + }, + "vertices": [] + } + ] + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..9aee4d16d --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json @@ -0,0 +1,253 @@ +{ + "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json", + "id": "eed88d95ad53e0f", + "definitionId": "3d3412c458178fff", + "name": "Fork-issues", + "description": "show case", + "createdAt": "2025-09-30T17:03:24.623714\u002B00:00", + "version": 12, + "toolVersion": "3.6.0.0", + "variables": [ + { + "id": "93b1a1cb9221a635", + "name": "myNumber", + "typeName": "Int32", + "isArray": false, + "value": "0", + "storageDriverTypeName": "Elsa.Workflows.WorkflowInstanceStorageDriver, Elsa.Workflows.Core" + } + ], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": { + "VariableTestValues": {} + }, + "isReadonly": false, + "isSystem": false, + "isLatest": true, + "isPublished": true, + "options": { + "autoUpdateConsumingWorkflows": false + }, + "root": { + "id": "8d3ecf38f4425d86", + "nodeId": "Workflow1:8d3ecf38f4425d86", + "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": "35c0f40b240666c6", + "nodeId": "Workflow1:8d3ecf38f4425d86:35c0f40b240666c6", + "name": "FlowDecision1", + "type": "Elsa.FlowDecision", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 337.5703125, + "y": 161.96484375 + }, + "size": { + "width": 149.84375, + "height": 67.9765625 + } + } + } + }, + { + "id": "342cf539286a991d", + "nodeId": "Workflow1:8d3ecf38f4425d86:342cf539286a991d", + "name": "Start1", + "type": "Elsa.Start", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 96.32421875, + "y": 48.98828125 + }, + "size": { + "width": 122.6484375, + "height": 67.9765625 + } + } + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "A" + } + }, + "id": "79e963ee9889c30a", + "nodeId": "Workflow1:8d3ecf38f4425d86:79e963ee9889c30a", + "name": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 609.34375, + "y": 48.98828125 + }, + "size": { + "width": 183.703125, + "height": 67.9765625 + } + }, + "displayText": "A" + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "B" + } + }, + "id": "af2cc31d1cef7a90", + "nodeId": "Workflow1:8d3ecf38f4425d86:af2cc31d1cef7a90", + "name": "WriteLine9", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 609.34375, + "y": 161.96484375 + }, + "size": { + "width": 159.6171875, + "height": 67.9765625 + } + }, + "displayText": "B" + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "C" + } + }, + "id": "768b5a7e58c3ecf8", + "nodeId": "Workflow1:8d3ecf38f4425d86:768b5a7e58c3ecf8", + "name": "WriteLine4", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false, + "mergeMode": "None" + }, + "metadata": { + "designer": { + "position": { + "x": 885.923828125, + "y": 48.98828125 + }, + "size": { + "width": 159.6171875, + "height": 67.9765625 + } + }, + "displayText": "C" + } + } + ], + "variables": [], + "connections": [ + { + "source": { + "activity": "79e963ee9889c30a", + "port": "Done" + }, + "target": { + "activity": "768b5a7e58c3ecf8", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "af2cc31d1cef7a90", + "port": "Done" + }, + "target": { + "activity": "768b5a7e58c3ecf8", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "342cf539286a991d", + "port": "Done" + }, + "target": { + "activity": "79e963ee9889c30a", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "342cf539286a991d", + "port": "Done" + }, + "target": { + "activity": "35c0f40b240666c6", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "35c0f40b240666c6", + "port": "True" + }, + "target": { + "activity": "af2cc31d1cef7a90", + "port": "In" + }, + "vertices": [] + } + ] + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-waitall.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-waitall.json new file mode 100644 index 000000000..a10bcfcd7 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-waitall.json @@ -0,0 +1,451 @@ +{ + "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json", + "id": "93277d4cd53f2dfc", + "definitionId": "3d3412c458178fff", + "name": "Fork-issues", + "description": "show case", + "createdAt": "2025-09-30T17:19:02.380015\u002B00:00", + "version": 14, + "toolVersion": "3.6.0.0", + "variables": [ + { + "id": "93b1a1cb9221a635", + "name": "myNumber", + "typeName": "Int32", + "isArray": false, + "value": "0" + } + ], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": { + "VariableTestValues": {} + }, + "isReadonly": false, + "isSystem": false, + "isLatest": true, + "isPublished": true, + "options": { + "autoUpdateConsumingWorkflows": false + }, + "root": { + "id": "8d3ecf38f4425d86", + "nodeId": "Workflow1:8d3ecf38f4425d86", + "type": "Elsa.Flowchart", + "version": 1, + "customProperties": { + "source": "FlowchartJsonConverter.cs:45", + "notFoundConnections": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {}, + "activities": [ + { + "mode": { + "typeName": "Elsa.Workflows.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core", + "expression": { + "type": "Literal", + "value": "WaitAll" + } + }, + "id": "80847eb320f89c72", + "nodeId": "Workflow1:8d3ecf38f4425d86:80847eb320f89c72", + "name": "FlowJoin1", + "type": "Elsa.FlowJoin", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 1241.6124954223633, + "y": -1.100006103515625 + }, + "size": { + "width": 118.7734375, + "height": 67.9765625 + } + } + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "F" + } + }, + "id": "8558cd1571aff0e8", + "nodeId": "Workflow1:8d3ecf38f4425d86:8558cd1571aff0e8", + "name": "F", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 1511.3999633789062, + "y": -1.100006103515625 + }, + "size": { + "width": 197.703125, + "height": 67.9765625 + } + }, + "displayText": "F" + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "E" + } + }, + "id": "af2cc31d1cef7a90", + "nodeId": "Workflow1:8d3ecf38f4425d86:af2cc31d1cef7a90", + "name": "E", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 900, + "y": 95.92343139648438 + }, + "size": { + "width": 159.6171875, + "height": 67.9765625 + } + }, + "displayText": "E" + } + }, + { + "condition": { + "typeName": "Boolean", + "expression": { + "type": "JavaScript", + "value": "return false;" + } + }, + "id": "35c0f40b240666c6", + "nodeId": "Workflow1:8d3ecf38f4425d86:35c0f40b240666c6", + "name": "FlowDecision1", + "type": "Elsa.FlowDecision", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 649.8249664306641, + "y": 163.89999389648438 + }, + "size": { + "width": 149.84375, + "height": 67.9765625 + } + } + } + }, + { + "code": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "110002" + } + }, + "category": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "failure" + } + }, + "faultType": null, + "message": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "that went south" + } + }, + "id": "cfffbc06a0b6eeb0", + "nodeId": "Workflow1:8d3ecf38f4425d86:cfffbc06a0b6eeb0", + "name": "Fault1", + "type": "Elsa.Fault", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 900, + "y": 254.2125015258789 + }, + "size": { + "width": 123.453125, + "height": 67.9765625 + } + } + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "D" + } + }, + "id": "896ddf80e59c66ef", + "nodeId": "Workflow1:8d3ecf38f4425d86:896ddf80e59c66ef", + "name": "D", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 327.39996337890625, + "y": 163.89999389648438 + }, + "size": { + "width": 242.828125, + "height": 67.9765625 + } + }, + "displayText": "D" + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "A" + } + }, + "id": "79e963ee9889c30a", + "nodeId": "Workflow1:8d3ecf38f4425d86:79e963ee9889c30a", + "name": "A", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": -20, + "y": -1.100006103515625 + }, + "size": { + "width": 183.703125, + "height": 67.9765625 + } + }, + "displayText": "A" + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "C" + } + }, + "id": "7302df5350ea8095", + "nodeId": "Workflow1:8d3ecf38f4425d86:7302df5350ea8095", + "name": "C", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 327.39996337890625, + "y": -1.100006103515625 + }, + "size": { + "width": 242.828125, + "height": 67.9765625 + } + }, + "displayText": "C" + } + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "B" + } + }, + "id": "c1a7d756c9ad7597", + "nodeId": "Workflow1:8d3ecf38f4425d86:c1a7d756c9ad7597", + "name": "B", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": 327.39996337890625, + "y": -144.10000610351562 + }, + "size": { + "width": 242.828125, + "height": 67.9765625 + } + }, + "displayText": "B" + } + } + ], + "variables": [], + "connections": [ + { + "source": { + "activity": "79e963ee9889c30a", + "port": "Done" + }, + "target": { + "activity": "7302df5350ea8095", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "79e963ee9889c30a", + "port": "Done" + }, + "target": { + "activity": "c1a7d756c9ad7597", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "79e963ee9889c30a", + "port": "Done" + }, + "target": { + "activity": "896ddf80e59c66ef", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "35c0f40b240666c6", + "port": "True" + }, + "target": { + "activity": "af2cc31d1cef7a90", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "896ddf80e59c66ef", + "port": "Done" + }, + "target": { + "activity": "35c0f40b240666c6", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "35c0f40b240666c6", + "port": "False" + }, + "target": { + "activity": "cfffbc06a0b6eeb0", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "c1a7d756c9ad7597", + "port": "Done" + }, + "target": { + "activity": "80847eb320f89c72", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "80847eb320f89c72", + "port": "Done" + }, + "target": { + "activity": "8558cd1571aff0e8", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "7302df5350ea8095", + "port": "Done" + }, + "target": { + "activity": "80847eb320f89c72", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "af2cc31d1cef7a90", + "port": "Done" + }, + "target": { + "activity": "80847eb320f89c72", + "port": "In" + }, + "vertices": [] + } + ] + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/join.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/join.json similarity index 100% rename from test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/join.json rename to test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/join.json diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/parallel-join.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/parallel-join.json similarity index 100% rename from test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImplicitJoins/Workflows/parallel-join.json rename to test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/parallel-join.json