diff --git a/Elsa.sln b/Elsa.sln
index eb2fc7827..e6a087781 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.Expressions.JavaScript/Providers/VariableTypeDefinitionProvider.cs b/src/modules/Elsa.Expressions.JavaScript/Providers/VariableTypeDefinitionProvider.cs
index 60bc39e8c..f6bcd0b85 100644
--- a/src/modules/Elsa.Expressions.JavaScript/Providers/VariableTypeDefinitionProvider.cs
+++ b/src/modules/Elsa.Expressions.JavaScript/Providers/VariableTypeDefinitionProvider.cs
@@ -3,6 +3,9 @@ using Elsa.Extensions;
using Elsa.Expressions.JavaScript.TypeDefinitions.Abstractions;
using Elsa.Expressions.JavaScript.TypeDefinitions.Contracts;
using Elsa.Expressions.JavaScript.TypeDefinitions.Models;
+using Elsa.Workflows.Management.Options;
+using JetBrains.Annotations;
+using Microsoft.Extensions.Options;
namespace Elsa.Expressions.JavaScript.Providers;
diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs
index 322e6dfc4..3940688e3 100644
--- a/src/modules/Elsa.Http/Features/HttpFeature.cs
+++ b/src/modules/Elsa.Http/Features/HttpFeature.cs
@@ -222,7 +222,7 @@ public class HttpFeature(IModule module) : FeatureBase(module)
.AddScoped()
//Trigger payload validators.
- .AddTriggerPaylodValidator()
+ .AddTriggerPayloadValidator()
// File caches.
.AddScoped(FileCache)
diff --git a/src/modules/Elsa.Scheduling/Bookmarks/CronBookmarkPayload.cs b/src/modules/Elsa.Scheduling/Bookmarks/CronBookmarkPayload.cs
index 609825ba1..d741dc594 100644
--- a/src/modules/Elsa.Scheduling/Bookmarks/CronBookmarkPayload.cs
+++ b/src/modules/Elsa.Scheduling/Bookmarks/CronBookmarkPayload.cs
@@ -1,3 +1,3 @@
namespace Elsa.Scheduling.Bookmarks;
-internal record CronBookmarkPayload(DateTimeOffset ExecuteAt, string CronExpression);
\ No newline at end of file
+public record CronBookmarkPayload(DateTimeOffset ExecuteAt, string CronExpression);
\ No newline at end of file
diff --git a/src/modules/Elsa.Scheduling/Bookmarks/StartAtPayload.cs b/src/modules/Elsa.Scheduling/Bookmarks/StartAtPayload.cs
index d8e816cb8..89fe0b909 100644
--- a/src/modules/Elsa.Scheduling/Bookmarks/StartAtPayload.cs
+++ b/src/modules/Elsa.Scheduling/Bookmarks/StartAtPayload.cs
@@ -1,3 +1,3 @@
namespace Elsa.Scheduling.Bookmarks;
-internal record StartAtPayload(DateTimeOffset ExecuteAt);
\ No newline at end of file
+public record StartAtPayload(DateTimeOffset ExecuteAt);
\ No newline at end of file
diff --git a/src/modules/Elsa.Scheduling/Bookmarks/TimerBookmarkPayload.cs b/src/modules/Elsa.Scheduling/Bookmarks/TimerBookmarkPayload.cs
index 43cfb0393..2afdd979a 100644
--- a/src/modules/Elsa.Scheduling/Bookmarks/TimerBookmarkPayload.cs
+++ b/src/modules/Elsa.Scheduling/Bookmarks/TimerBookmarkPayload.cs
@@ -1,3 +1,3 @@
namespace Elsa.Scheduling.Bookmarks;
-internal record TimerBookmarkPayload(DateTimeOffset ResumeAt);
\ No newline at end of file
+public record TimerBookmarkPayload(DateTimeOffset ResumeAt);
\ No newline at end of file
diff --git a/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs b/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs
index 9dd4ce1d4..48b25c576 100644
--- a/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs
+++ b/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs
@@ -48,13 +48,12 @@ public class SchedulingFeature : FeatureBase
.AddScoped()
.AddScoped()
.AddScoped()
- .AddSingleton(CronParser)
.AddScoped(WorkflowScheduler)
.AddBackgroundTask()
.AddHandlersFrom()
//Trigger payload validators.
- .AddTriggerPaylodValidator();
+ .AddTriggerPayloadValidator();
Module.Configure(management => management.AddActivitiesFrom());
}
diff --git a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs
index 89a75575d..822d717d5 100644
--- a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs
+++ b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs
@@ -86,41 +86,49 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
_timer.Elapsed += async (_, _) =>
{
- _timer?.Dispose();
- _timer = null;
- _startAt = _systemClock.UtcNow + _interval;
-
- using var scope = _scopeFactory.CreateScope();
- var commandSender = scope.ServiceProvider.GetRequiredService();
- var cancellationToken = _cancellationTokenSource.Token;
- if (!cancellationToken.IsCancellationRequested)
+ try
{
- try
- {
- var acquired = await _executionSemaphore.WaitAsync(0, cancellationToken);
- if (!acquired) return;
- _executing = true;
- await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken);
- if (_cancellationRequested)
+ _timer?.Dispose();
+ _timer = null;
+ _startAt = _systemClock.UtcNow + _interval;
+
+ using var scope = _scopeFactory.CreateScope();
+ var commandSender = scope.ServiceProvider.GetRequiredService();
+ var cancellationToken = _cancellationTokenSource.Token;
+ if (!cancellationToken.IsCancellationRequested)
+ {
+ try
{
- _cancellationRequested = false;
- _cancellationTokenSource.Cancel();
+ var acquired = await _executionSemaphore.WaitAsync(0, cancellationToken);
+ if (!acquired) return;
+ _executing = true;
+ await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken);
+
+ if (_cancellationRequested)
+ {
+ _cancellationRequested = false;
+ _cancellationTokenSource.Cancel();
+ }
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Error executing scheduled task");
+ }
+ finally
+ {
+ _executing = false;
+ _executionSemaphore.Release();
}
}
- catch (Exception e)
- {
- _logger.LogError(e, "Error executing scheduled task");
- }
- finally
- {
- _executing = false;
- _executionSemaphore.Release();
- }
- }
- if (!cancellationToken.IsCancellationRequested)
- Schedule();
+ if (!cancellationToken.IsCancellationRequested)
+ Schedule();
+ }
+ catch (ObjectDisposedException ex)
+ {
+ _logger.LogWarning(ex, "Service Provider was disposed.");
+ }
};
}
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs
index 6148de9e6..a7afdcfe4 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs
@@ -29,19 +29,24 @@ internal class PostEndpoint(
{
PostRequest? request = null;
- if (HttpContext.Request.ContentLength > 0 && (HttpContext.Request.ContentType?.Contains("application/json") ?? true))
+ if (HttpContext.Request.ContentType?.Contains("application/json") ?? false)
{
- try
+ using var reader = new StreamReader(HttpContext.Request.Body);
+ var body = await reader.ReadToEndAsync();
+
+ if (!string.IsNullOrWhiteSpace(body))
{
- request = await JsonSerializer.DeserializeAsync(HttpContext.Request.Body,
- new JsonSerializerOptions
+ try
{
- PropertyNameCaseInsensitive = true
- }, cancellationToken: cancellationToken);
- }
- catch
- {
- AddError("Invalid request body.");
+ request = JsonSerializer.Deserialize(body, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+ }
+ catch
+ {
+ AddError("Invalid request body.");
+ }
}
}
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/Activities/Flowchart.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs
index e5b868ab4..6c72282e7 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs
@@ -30,7 +30,7 @@ public partial class Flowchart : Container
///
/// The activity to execute when the flowchart starts.
///
- [Port] [Browsable(false)] public IActivity? Start { get; set; }
+ [Port][Browsable(false)] public IActivity? Start { get; set; }
///
/// A list of connections between activities.
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/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowDefinitionPublisher.cs b/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowDefinitionPublisher.cs
index c9df69142..ec36ebeed 100644
--- a/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowDefinitionPublisher.cs
+++ b/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowDefinitionPublisher.cs
@@ -59,6 +59,15 @@ public interface IWorkflowDefinitionPublisher
/// The cancellation token.
/// The retracted workflow definition.
Task RetractAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates a new workflow definition from the specified version.
+ ///
+ /// The definition ID.
+ /// The version number.
+ /// The cancellation token.
+ /// The new workflow definition.
+ Task RevertVersionAsync(string definitionId, int version, CancellationToken cancellationToken = default);
///
/// Gets or creates a draft for the specified workflow definition.
diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionManager.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionManager.cs
index 65129853e..c6202d065 100644
--- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionManager.cs
+++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionManager.cs
@@ -7,43 +7,32 @@ using Elsa.Workflows.Management.Notifications;
namespace Elsa.Workflows.Management.Services;
///
-public class WorkflowDefinitionManager : IWorkflowDefinitionManager
+public class WorkflowDefinitionManager(
+ IWorkflowDefinitionStore store,
+ INotificationSender notificationSender,
+ IWorkflowDefinitionPublisher workflowPublisher) : IWorkflowDefinitionManager
{
- private readonly IWorkflowDefinitionStore _store;
- private readonly INotificationSender _notificationSender;
- private readonly IWorkflowDefinitionPublisher _workflowPublisher;
- private readonly IIdentityGenerator _identityGenerator;
-
- ///
- /// Constructor.
- ///
- public WorkflowDefinitionManager(
- IWorkflowDefinitionStore store,
- INotificationSender notificationSender,
- IWorkflowDefinitionPublisher workflowPublisher,
- IIdentityGenerator identityGenerator)
- {
- _store = store;
- _notificationSender = notificationSender;
- _workflowPublisher = workflowPublisher;
- _identityGenerator = identityGenerator;
- }
-
///
public async Task DeleteByDefinitionIdAsync(string definitionId, CancellationToken cancellationToken = default)
{
- await _notificationSender.SendAsync(new WorkflowDefinitionDeleting(definitionId), cancellationToken);
- var filter = new WorkflowDefinitionFilter { DefinitionId = definitionId };
- var count = await _store.DeleteAsync(filter, cancellationToken);
- await _notificationSender.SendAsync(new WorkflowDefinitionDeleted(definitionId), cancellationToken);
+ await notificationSender.SendAsync(new WorkflowDefinitionDeleting(definitionId), cancellationToken);
+ var filter = new WorkflowDefinitionFilter
+ {
+ DefinitionId = definitionId
+ };
+ var count = await store.DeleteAsync(filter, cancellationToken);
+ await notificationSender.SendAsync(new WorkflowDefinitionDeleted(definitionId), cancellationToken);
return count;
}
///
public async Task DeleteByIdAsync(string id, CancellationToken cancellationToken = default)
{
- var filter = new WorkflowDefinitionFilter { Id = id };
- var definition = await _store.FindAsync(filter, cancellationToken);
+ var filter = new WorkflowDefinitionFilter
+ {
+ Id = id
+ };
+ var definition = await store.FindAsync(filter, cancellationToken);
if (definition == null)
return false;
@@ -55,11 +44,15 @@ public class WorkflowDefinitionManager : IWorkflowDefinitionManager
public async Task BulkDeleteByDefinitionIdsAsync(IEnumerable definitionIds, CancellationToken cancellationToken = default)
{
var definitionIdList = definitionIds.Distinct().ToList();
- await _notificationSender.SendAsync(new WorkflowDefinitionsDeleting(definitionIdList), cancellationToken);
- var filter = new WorkflowDefinitionFilter { DefinitionIds = definitionIdList, IsReadonly = false };
- var count = await _store.DeleteAsync(filter, cancellationToken);
+ await notificationSender.SendAsync(new WorkflowDefinitionsDeleting(definitionIdList), cancellationToken);
+ var filter = new WorkflowDefinitionFilter
+ {
+ DefinitionIds = definitionIdList,
+ IsReadonly = false
+ };
+ var count = await store.DeleteAsync(filter, cancellationToken);
await EnsureLastVersionIsLatestAsync(definitionIdList, cancellationToken);
- await _notificationSender.SendAsync(new WorkflowDefinitionsDeleted(definitionIdList), cancellationToken);
+ await notificationSender.SendAsync(new WorkflowDefinitionsDeleted(definitionIdList), cancellationToken);
return count;
}
@@ -67,21 +60,31 @@ public class WorkflowDefinitionManager : IWorkflowDefinitionManager
public async Task BulkDeleteByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default)
{
var idList = ids.ToList();
- var definitions = await _store.FindSummariesAsync(new WorkflowDefinitionFilter { Ids = idList }, cancellationToken);
+ var definitions = await store.FindSummariesAsync(new WorkflowDefinitionFilter
+ {
+ Ids = idList
+ }, cancellationToken);
var definitionIds = definitions.Select(x => x.DefinitionId).Distinct().ToList();
- await _notificationSender.SendAsync(new WorkflowDefinitionVersionsDeleting(idList), cancellationToken);
- var filter = new WorkflowDefinitionFilter { Ids = idList };
- var count = await _store.DeleteAsync(filter, cancellationToken);
+ await notificationSender.SendAsync(new WorkflowDefinitionVersionsDeleting(idList), cancellationToken);
+ var filter = new WorkflowDefinitionFilter
+ {
+ Ids = idList
+ };
+ var count = await store.DeleteAsync(filter, cancellationToken);
await EnsureLastVersionIsLatestAsync(definitionIds, cancellationToken);
- await _notificationSender.SendAsync(new WorkflowDefinitionVersionsDeleted(idList), cancellationToken);
+ await notificationSender.SendAsync(new WorkflowDefinitionVersionsDeleted(idList), cancellationToken);
return count;
}
///
public async Task DeleteVersionAsync(string definitionId, int versionToDelete, CancellationToken cancellationToken = default)
{
- var filter = new WorkflowDefinitionFilter { DefinitionId = definitionId, VersionOptions = VersionOptions.SpecificVersion(versionToDelete) };
- var definitionToDelete = await _store.FindAsync(filter, cancellationToken);
+ var filter = new WorkflowDefinitionFilter
+ {
+ DefinitionId = definitionId,
+ VersionOptions = VersionOptions.SpecificVersion(versionToDelete)
+ };
+ var definitionToDelete = await store.FindAsync(filter, cancellationToken);
if (definitionToDelete == null)
return false;
@@ -94,42 +97,30 @@ public class WorkflowDefinitionManager : IWorkflowDefinitionManager
{
if (definitionToDelete.IsPublished)
{
- await _workflowPublisher.RetractAsync(definitionToDelete, cancellationToken);
+ await workflowPublisher.RetractAsync(definitionToDelete, cancellationToken);
}
- await _notificationSender.SendAsync(new WorkflowDefinitionVersionDeleting(definitionToDelete), cancellationToken);
+ await notificationSender.SendAsync(new WorkflowDefinitionVersionDeleting(definitionToDelete), cancellationToken);
- var filter = new WorkflowDefinitionFilter { Id = definitionToDelete.Id };
- var isDeleted = await _store.DeleteAsync(filter, cancellationToken) > 0;
+ var filter = new WorkflowDefinitionFilter
+ {
+ Id = definitionToDelete.Id
+ };
+ var isDeleted = await store.DeleteAsync(filter, cancellationToken) > 0;
if (!isDeleted)
return false;
await EnsureLastVersionIsLatestAsync(definitionToDelete.DefinitionId, cancellationToken);
- await _notificationSender.SendAsync(new WorkflowDefinitionVersionDeleted(definitionToDelete), cancellationToken);
+ await notificationSender.SendAsync(new WorkflowDefinitionVersionDeleted(definitionToDelete), cancellationToken);
return isDeleted;
}
///
- public async Task RevertVersionAsync(string definitionId, int version, CancellationToken cancellationToken = default)
+ public Task RevertVersionAsync(string definitionId, int version, CancellationToken cancellationToken = default)
{
- var filter = new WorkflowDefinitionFilter { DefinitionId = definitionId, VersionOptions = VersionOptions.Latest };
- var latestVersion = await _store.FindAsync(filter, cancellationToken);
-
- if (latestVersion != null)
- {
- latestVersion.IsLatest = false;
- await _store.SaveAsync(latestVersion, cancellationToken);
- }
-
- var draft = await _workflowPublisher.GetDraftAsync(definitionId, VersionOptions.SpecificVersion(version), cancellationToken);
- draft!.Id = _identityGenerator.GenerateId();
- draft.Version = (latestVersion?.Version ?? 0) + 1;
- draft.IsLatest = true;
-
- await _store.SaveAsync(draft, cancellationToken);
- return draft;
+ return workflowPublisher.RevertVersionAsync(definitionId, version, cancellationToken);
}
private async Task EnsureLastVersionIsLatestAsync(IEnumerable definitionIds, CancellationToken cancellationToken)
@@ -145,13 +136,16 @@ public class WorkflowDefinitionManager : IWorkflowDefinitionManager
/// The cancellation token.
private async Task EnsureLastVersionIsLatestAsync(string definitionId, CancellationToken cancellationToken)
{
- var filter = new WorkflowDefinitionFilter { DefinitionId = definitionId };
- var lastVersion = await _store.FindLastVersionAsync(filter, cancellationToken);
+ var filter = new WorkflowDefinitionFilter
+ {
+ DefinitionId = definitionId
+ };
+ var lastVersion = await store.FindLastVersionAsync(filter, cancellationToken);
if (lastVersion is null)
return;
lastVersion.IsLatest = true;
- await _store.SaveAsync(lastVersion, cancellationToken);
+ await store.SaveAsync(lastVersion, cancellationToken);
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs
index 0fc3c4d90..007fc855c 100644
--- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs
+++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs
@@ -62,7 +62,7 @@ public class WorkflowDefinitionPublisher(
StringData = activitySerializer.Serialize(root),
MaterializerName = JsonWorkflowMaterializer.MaterializerName
};
-
+
return Task.FromResult(workflowDefinition);
}
@@ -71,7 +71,7 @@ public class WorkflowDefinitionPublisher(
{
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
var definition = await workflowDefinitionStore.FindAsync(filter, cancellationToken);
-
+
if (definition == null)
return new(false, new List
{
@@ -148,6 +148,30 @@ public class WorkflowDefinitionPublisher(
return definition;
}
+ public async Task RevertVersionAsync(string definitionId, int version, CancellationToken cancellationToken = default)
+ {
+ var filter = new WorkflowDefinitionFilter
+ {
+ DefinitionId = definitionId,
+ VersionOptions = VersionOptions.Latest
+ };
+ var latestVersion = await workflowDefinitionStore.FindAsync(filter, cancellationToken);
+
+ if (latestVersion != null)
+ {
+ latestVersion.IsLatest = false;
+ await workflowDefinitionStore.SaveAsync(latestVersion, cancellationToken);
+ }
+
+ var draft = await GetDraftAsync(definitionId, VersionOptions.SpecificVersion(version), cancellationToken);
+ draft!.Id = identityGenerator.GenerateId();
+ draft.Version = (latestVersion?.Version ?? 0) + 1;
+ draft.IsLatest = true;
+
+ await workflowDefinitionStore.SaveAsync(draft, cancellationToken);
+ return draft;
+ }
+
///
public async Task GetDraftAsync(string definitionId, VersionOptions versionOptions, CancellationToken cancellationToken = default)
{
@@ -199,7 +223,7 @@ public class WorkflowDefinitionPublisher(
await workflowDefinitionStore.SaveAsync(draft, cancellationToken);
await mediator.SendAsync(new WorkflowDefinitionDraftSaved(draft), cancellationToken);
- if (lastVersion is null)
+ if (lastVersion is null)
await mediator.SendAsync(new WorkflowDefinitionCreated(definition), cancellationToken);
if (lastVersion is { IsPublished: true, IsLatest: true })
diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs
index 5fb45c5a7..855889c9a 100644
--- a/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs
@@ -22,7 +22,7 @@ public static class DependencyInjectionExtensions
/// Service collection
/// Validator of the validator
/// Payload type
- public static IServiceCollection AddTriggerPaylodValidator(this IServiceCollection services)
+ public static IServiceCollection AddTriggerPayloadValidator(this IServiceCollection services)
where TValidator : class, ITriggerPayloadValidator
{
return services.AddScoped, TValidator>();
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..7a0fe6d1c 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs
@@ -32,7 +32,10 @@ public class FlowchartNextActivityTests
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync();
var lines = _capturingTextWriter.Lines.ToList();
- Assert.Equal(new[] { "Line 1" }, lines);
+ Assert.Equal(new[]
+ {
+ "Line 1"
+ }, lines);
}
[Fact(DisplayName = "Flowchart with backward connections and a dangling activity")]
@@ -44,74 +47,71 @@ public class FlowchartNextActivityTests
var start = new Start();
var dangling = new WriteLine("dangling");
- var writeLineDecision = new FlowSwitch()
+ var writeLineDecision = new FlowSwitch
{
- Cases = {
- new FlowSwitchCase("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
- new FlowSwitchCase("LessThanOne", new Expression("JavaScript", "getVariable('LoopCount') < 1")),
- },
+ Cases =
+ {
+ new("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3"))
+ },
Mode = new(SwitchMode.MatchAny)
};
var a = new WriteLine("A");
var b = new WriteLine("B");
- var c = new WriteLine("C");
- var incrementLoop = new SetVariable()
+ var incrementLoop = new SetVariable
{
Variable = loopVariable,
- Value = new Models.Input