Merge branch 'develop/3.6.0' into feat/test-guidelines

This commit is contained in:
lucas.hipolito 2025-10-06 13:45:41 +02:00
commit 4d17f869a3
33 changed files with 1598 additions and 301 deletions

View file

@ -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}"

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
None,
/// <summary>
/// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
/// Use for required synchronization points.
/// </summary>
Converge,
/// <summary>
/// Schedule on each arriving token, allowing multiple executions if supported.
/// </summary>
Stream,
/// <summary>
/// Schedule on the first arriving token, block or cancel others.
/// </summary>
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.

View file

@ -121,7 +121,7 @@ public static class ActivityExtensions
public static MergeMode GetMergeMode(this JsonObject activity)
{
return activity.GetProperty<MergeMode?>("customProperties", "mergeMode") ?? MergeMode.Converge;
return activity.GetProperty<MergeMode?>("customProperties", "mergeMode") ?? MergeMode.None;
}
public static void SetMergeMode(this JsonObject activity, MergeMode? value)

View file

@ -6,17 +6,24 @@ namespace Elsa.Api.Client.Shared.Enums;
public enum MergeMode
{
/// <summary>
/// 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.
/// </summary>
None,
/// <summary>
/// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
/// Use for required synchronization points.
/// </summary>
Converge,
/// <summary>
/// Proceed when any one inbound path completes; cancel all others.
/// Schedule on each arriving token, allowing multiple executions if supported.
/// </summary>
Race,
Stream,
/// <summary>
/// Proceed when any one inbound path completes; do not cancel others.
/// Schedule on the first arriving token, block or cancel others.
/// </summary>
Stream
Race
}

View file

@ -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;

View file

@ -222,7 +222,7 @@ public class HttpFeature(IModule module) : FeatureBase(module)
.AddScoped<IDownloadableContentHandler, HttpFileDownloadableContentHandler>()
//Trigger payload validators.
.AddTriggerPaylodValidator<HttpEndpointTriggerPayloadValidator, HttpEndpointBookmarkPayload>()
.AddTriggerPayloadValidator<HttpEndpointTriggerPayloadValidator, HttpEndpointBookmarkPayload>()
// File caches.
.AddScoped(FileCache)

View file

@ -1,3 +1,3 @@
namespace Elsa.Scheduling.Bookmarks;
internal record CronBookmarkPayload(DateTimeOffset ExecuteAt, string CronExpression);
public record CronBookmarkPayload(DateTimeOffset ExecuteAt, string CronExpression);

View file

@ -1,3 +1,3 @@
namespace Elsa.Scheduling.Bookmarks;
internal record StartAtPayload(DateTimeOffset ExecuteAt);
public record StartAtPayload(DateTimeOffset ExecuteAt);

View file

@ -1,3 +1,3 @@
namespace Elsa.Scheduling.Bookmarks;
internal record TimerBookmarkPayload(DateTimeOffset ResumeAt);
public record TimerBookmarkPayload(DateTimeOffset ResumeAt);

View file

@ -48,13 +48,12 @@ public class SchedulingFeature : FeatureBase
.AddScoped<ITriggerScheduler, DefaultTriggerScheduler>()
.AddScoped<IBookmarkScheduler, DefaultBookmarkScheduler>()
.AddScoped<DefaultWorkflowScheduler>()
.AddSingleton(CronParser)
.AddScoped(WorkflowScheduler)
.AddBackgroundTask<CreateSchedulesBackgroundTask>()
.AddHandlersFrom<ScheduleWorkflows>()
//Trigger payload validators.
.AddTriggerPaylodValidator<CronTriggerPayloadValidator, CronTriggerPayload>();
.AddTriggerPayloadValidator<CronTriggerPayloadValidator, CronTriggerPayload>();
Module.Configure<WorkflowManagementFeature>(management => management.AddActivitiesFrom<SchedulingFeature>());
}

View file

@ -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<ICommandSender>();
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<ICommandSender>();
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.");
}
};
}

View file

@ -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<PostRequest>(HttpContext.Request.Body,
new JsonSerializerOptions
try
{
PropertyNameCaseInsensitive = true
}, cancellationToken: cancellationToken);
}
catch
{
AddError("Invalid request body.");
request = JsonSerializer.Deserialize<PostRequest>(body, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
catch
{
AddError("Invalid request body.");
}
}
}

View file

@ -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);
}

View file

@ -30,7 +30,7 @@ public partial class Flowchart : Container
/// <summary>
/// The activity to execute when the flowchart starts.
/// </summary>
[Port] [Browsable(false)] public IActivity? Start { get; set; }
[Port][Browsable(false)] public IActivity? Start { get; set; }
/// <summary>
/// A list of connections between activities.

View file

@ -6,17 +6,24 @@ namespace Elsa.Workflows.Activities.Flowchart.Models;
public enum MergeMode
{
/// <summary>
/// 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.
/// </summary>
None,
/// <summary>
/// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
/// Use for required synchronization points.
/// </summary>
Converge,
/// <summary>
/// Proceed when any one inbound path completes; cancel all others.
/// Schedule on each arriving token, allowing multiple executions if supported.
/// </summary>
Race,
Stream,
/// <summary>
/// Proceed when any one inbound path completes; do not cancel others.
/// Schedule on the first arriving token, block or cancel others.
/// </summary>
Stream
Race
}

View file

@ -59,6 +59,15 @@ public interface IWorkflowDefinitionPublisher
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The retracted workflow definition.</returns>
Task<WorkflowDefinition> RetractAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new workflow definition from the specified version.
/// </summary>
/// <param name="definitionId">The definition ID.</param>
/// <param name="version">The version number.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The new workflow definition.</returns>
Task<WorkflowDefinition> RevertVersionAsync(string definitionId, int version, CancellationToken cancellationToken = default);
/// <summary>
/// Gets or creates a draft for the specified workflow definition.

View file

@ -7,43 +7,32 @@ using Elsa.Workflows.Management.Notifications;
namespace Elsa.Workflows.Management.Services;
/// <inheritdoc />
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;
/// <summary>
/// Constructor.
/// </summary>
public WorkflowDefinitionManager(
IWorkflowDefinitionStore store,
INotificationSender notificationSender,
IWorkflowDefinitionPublisher workflowPublisher,
IIdentityGenerator identityGenerator)
{
_store = store;
_notificationSender = notificationSender;
_workflowPublisher = workflowPublisher;
_identityGenerator = identityGenerator;
}
/// <inheritdoc />
public async Task<long> 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;
}
/// <inheritdoc />
public async Task<bool> 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<long> BulkDeleteByDefinitionIdsAsync(IEnumerable<string> 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<long> BulkDeleteByIdsAsync(IEnumerable<string> 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;
}
/// <inheritdoc />
public async Task<bool> 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;
}
/// <inheritdoc />
public async Task<WorkflowDefinition> RevertVersionAsync(string definitionId, int version, CancellationToken cancellationToken = default)
public Task<WorkflowDefinition> 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<string> definitionIds, CancellationToken cancellationToken)
@ -145,13 +136,16 @@ public class WorkflowDefinitionManager : IWorkflowDefinitionManager
/// <param name="cancellationToken">The cancellation token.</param>
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);
}
}

View file

@ -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<WorkflowValidationError>
{
@ -148,6 +148,30 @@ public class WorkflowDefinitionPublisher(
return definition;
}
public async Task<WorkflowDefinition> 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;
}
/// <inheritdoc />
public async Task<WorkflowDefinition?> 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 })

View file

@ -22,7 +22,7 @@ public static class DependencyInjectionExtensions
/// <param name="services">Service collection</param>
/// <typeparam name="TValidator">Validator of the validator</typeparam>
/// <typeparam name="TPayload">Payload type</typeparam>
public static IServiceCollection AddTriggerPaylodValidator<TValidator, TPayload>(this IServiceCollection services)
public static IServiceCollection AddTriggerPayloadValidator<TValidator, TPayload>(this IServiceCollection services)
where TValidator : class, ITriggerPayloadValidator<TPayload>
{
return services.AddScoped<ITriggerPayloadValidator<TPayload>, TValidator>();

View file

@ -23,5 +23,14 @@
<None Update="Scenarios\DependencyWorkflowsPublishing\Workflows\parent.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\ImplicitJoins\Workflows\fork-decision-join-converge.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\ImplicitJoins\Workflows\fork-decision-join-none.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\JoinBehaviors\Workflows\fork-decision-join-waitall.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -32,7 +32,10 @@ public class FlowchartNextActivityTests
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<FlowchartWorkflow>();
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<object?>(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
};
var loopbackDecision = new FlowSwitch()
var loopbackDecision = new FlowSwitch
{
Cases = {
new FlowSwitchCase("EqualOne", new Expression("JavaScript", "getVariable('LoopCount') == 1")),
new FlowSwitchCase("LessThanFour", new Expression("JavaScript", "getVariable('LoopCount') < 4")),
},
Mode = new(SwitchMode.MatchFirst)
Cases =
{
new("EqualOne", new Expression("JavaScript", "getVariable('LoopCount') == 1")),
new("LessThanFour", new Expression("JavaScript", "getVariable('LoopCount') < 4")),
new("EqualThree", new Expression("JavaScript", "getVariable('LoopCount') == 3")),
},
Mode = new(SwitchMode.MatchAny)
};
var d = new WriteLine("D");
var e = new WriteLine("E");
var f = new WriteLine("F");
var end = new End();
workflowBuilder.Root = new Flowchart
{
Variables =
{
loopVariable
},
{
loopVariable
},
Activities =
{
start,
dangling,
writeLineDecision,
a,
b,
c,
incrementLoop,
loopbackDecision,
d,
e,
f,
end
},
{
start,
dangling,
writeLineDecision,
a,
b,
incrementLoop,
loopbackDecision,
d,
e,
f,
end
},
Connections =
{
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, "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, "EqualThree"), new Endpoint(f)),
new(f, end),
}
};
@ -121,7 +121,10 @@ public class FlowchartNextActivityTests
var result = await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
Assert.Equal(new[] { "A", "B", "C", "D", "E", "A", "B", "E", "F" }, lines);
Assert.Equal(new[]
{
"A", "B", "D", "E", "A", "B", "E", "E", "F"
}, lines);
}
[Fact(DisplayName = "Flowchart with an invalid backward connection")]
@ -132,24 +135,42 @@ public class FlowchartNextActivityTests
var workflow = new TestWorkflow(workflowBuilder =>
{
var start = new Start() { Id = "Start" };
var a = new WriteLine("A") { Id = "WriteLineA" };
var b = new WriteLine("B") { Id = "WriteLineB" };
var c = new WriteLine("C") { Id = "WriteLineC" };
var d = new WriteLine("D") { Id = "WriteLineD" };
var e = new WriteLine("E") { Id = "WriteLineE" };
var start = new Start
{
Id = "Start"
};
var a = new WriteLine("A")
{
Id = "WriteLineA"
};
var b = new WriteLine("B")
{
Id = "WriteLineB"
};
var c = new WriteLine("C")
{
Id = "WriteLineC"
};
var d = new WriteLine("D")
{
Id = "WriteLineD"
};
var e = new WriteLine("E")
{
Id = "WriteLineE"
};
workflowBuilder.Root = new Flowchart
{
Activities =
{
start,
a,
b,
c,
d,
e,
},
{
start,
a,
b,
c,
d,
e,
},
Connections =
{
new(start, a),
@ -169,7 +190,10 @@ public class FlowchartNextActivityTests
Assert.Equal(WorkflowSubStatus.Faulted, result.WorkflowState.SubStatus);
Assert.Equal(1, result.WorkflowState.Incidents.Count());
Assert.Equal("Invalid backward connection: Every path from the source ('WriteLineE') must go through the target ('WriteLineC') when tracing back to the start.", result.WorkflowState.Incidents.First().Message);
Assert.Equal(new[] { "A", "B", "C", "D", "E" }, lines);
Assert.Equal(new[]
{
"A", "B", "C", "D", "E"
}, lines);
}
[Theory(DisplayName = "Flowchart with a Join activity executed multiple times")]
@ -186,43 +210,42 @@ public class FlowchartNextActivityTests
var b = new WriteLine("B");
var c = new WriteLine("C");
var d = new WriteLine("D");
var join = new FlowJoin()
var join = new FlowJoin
{
Mode = new(joinMode)
};
var incrementLoop = new SetVariable()
var incrementLoop = new SetVariable
{
Variable = loopVariable,
Value = new Models.Input<object?>(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
};
var loopbackDecision = new FlowSwitch()
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 =
{
loopVariable
},
{
loopVariable
},
Activities =
{
start,
a,
b,
c,
d,
join,
incrementLoop,
loopbackDecision,
end
},
{
start,
a,
b,
c,
d,
join,
incrementLoop,
loopbackDecision,
end
},
Connections =
{
new(start, a),
@ -234,8 +257,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 +268,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")]
@ -258,49 +280,48 @@ public class FlowchartNextActivityTests
var loopVariable = new Variable<int>("LoopCount", 0);
var start = new Start();
var loopbackSwitch = new FlowSwitch()
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)
};
var a = new WriteLine("A");
var incrementLoop = new SetVariable()
var incrementLoop = new SetVariable
{
Variable = loopVariable,
Value = new Models.Input<object?>(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
};
var join = new FlowJoin()
var join = new FlowJoin
{
Mode = new(joinMode)
};
var b = new WriteLine("B");
var end = new End();
workflowBuilder.Root = new Flowchart
{
Variables =
{
loopVariable
},
{
loopVariable
},
Activities =
{
start,
loopbackSwitch,
a,
incrementLoop,
join,
b,
end
},
{
start,
loopbackSwitch,
a,
incrementLoop,
join,
b,
end
},
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),
@ -313,6 +334,9 @@ public class FlowchartNextActivityTests
var result = await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
Assert.Equal(new[] { "A", "A", "A", "B" }, lines);
Assert.Equal(new[]
{
"A", "A", "A", "B"
}, lines);
}
}

View file

@ -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
{

View file

@ -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);
}
}

View file

@ -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
{

View file

@ -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<IWorkflowExecutionLogStore>().FindManyAsync(new WorkflowExecutionLogRecordFilter
var journal = await _services.GetRequiredService<IWorkflowExecutionLogStore>().FindManyAsync(new()
{
WorkflowInstanceId = state.Id,
ActivityId = "802725996be1b582",

View file

@ -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);

View file

@ -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),
}
};
}

View file

@ -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),
}
};
}

View file

@ -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": []
}
]
}
}

View file

@ -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": []
}
]
}
}

View file

@ -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": []
}
]
}
}