From 5dfe13cdcb073949667b621e18be60c5964cc9e6 Mon Sep 17 00:00:00 2001 From: bobhauser Date: Tue, 11 Nov 2025 11:21:58 -0500 Subject: [PATCH 01/27] Restore pre-3.5 Join WaitAll behavior (#7006) Co-authored-by: Bob Hauser Co-authored-by: Sipke Schoorstra --- .../Flowchart/Activities/FlowJoin.cs | 25 +- .../Flowchart/Activities/Flowchart.cs | 258 ++++++++++-------- .../Flowchart/Models/FlowJoinMode.cs | 3 +- .../Activities/Flowchart/Models/FlowScope.cs | 17 +- .../Scenarios/FlowchartNextActivity/Tests.cs | 89 +++++- 5 files changed, 249 insertions(+), 143 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs index 067efafc8..a383e3598 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs @@ -1,7 +1,5 @@ using System.Runtime.CompilerServices; -using Elsa.Extensions; using Elsa.Workflows.Activities.Flowchart.Contracts; -using Elsa.Workflows.Activities.Flowchart.Extensions; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Attributes; using Elsa.Workflows.Models; @@ -35,26 +33,7 @@ public class FlowJoin : Activity, IJoinNode /// protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { - var mode = context.Get(Mode); - - switch (mode) - { - case FlowJoinMode.WaitAll: - { - if (Flowchart.CanWaitAllProceed(context)) - { - await Flowchart.CancelAncestorActivitiesAsync(context); - await context.CompleteActivityAsync(); - } - - break; - } - case FlowJoinMode.WaitAny: - { - await Flowchart.CancelAncestorActivitiesAsync(context); - await context.CompleteActivityAsync(); - break; - } - } + // The join behavior is handled by Flowchart, so we can simply complete the activity here. + await context.CompleteActivityAsync(); } } \ No newline at end of file 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 217a9315f..3f69befee 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs @@ -1,7 +1,6 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using Elsa.Extensions; -using Elsa.Workflows.Activities.Flowchart.Contracts; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Attributes; using Elsa.Workflows.Options; @@ -95,11 +94,18 @@ public class Flowchart : Container private bool HasPendingWork(ActivityExecutionContext context) { var workflowExecutionContext = context.WorkflowExecutionContext; - var activityIds = Activities.Select(x => x.Id).ToList(); - var children = context.Children; - var hasRunningActivityInstances = children.Where(x => activityIds.Contains(x.Activity.Id)).Any(x => x.Status == ActivityStatus.Running); - var hasPendingWork = workflowExecutionContext.Scheduler.List().Any(workItem => + // Use HashSet for O(1) lookups + var activityIds = new HashSet(Activities.Select(x => x.Id)); + + // Short circuit evaluation - check running instances first before more expensive scheduler check + if (context.Children.Any(x => activityIds.Contains(x.Activity.Id) && x.Status == ActivityStatus.Running)) + return true; + + // Scheduler check - optimize to avoid repeated LINQ evaluations + var scheduledItems = workflowExecutionContext.Scheduler.List().ToList(); + + return scheduledItems.Any(workItem => { var ownerInstanceId = workItem.Owner?.Id; @@ -109,13 +115,9 @@ public class Flowchart : Container if (ownerInstanceId == context.Id) return true; - var ownerContext = context.WorkflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId); - var ancestors = ownerContext.GetAncestors().ToList(); - - return ancestors.Any(x => x == context); + var ownerContext = workflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId); + return ownerContext.GetAncestors().Any(x => x == context); }); - - return hasRunningActivityInstances || hasPendingWork; } private IActivity? GetRootActivity() @@ -149,6 +151,14 @@ public class Flowchart : Container var completedActivity = completedActivityContext.Activity; var result = context.Result; + // Determine the outcomes from the completed activity + var outcomes = result is Outcomes o ? o : Outcomes.Default; + + await ProcessChildCompletedAsync(flowchartContext, completedActivity, completedActivityContext, outcomes); + } + + private async ValueTask ProcessChildCompletedAsync(ActivityExecutionContext flowchartContext, IActivity completedActivity, ActivityExecutionContext completedActivityContext, Outcomes outcomes) + { if (flowchartContext.Activity != this) { throw new("Target context activity must be this flowchart"); @@ -167,16 +177,13 @@ public class Flowchart : Container return; } - // Determine the outcomes from the completed activity. - var outcomes = result is Outcomes o ? o : Outcomes.Default; - // Schedule the outbound activities var flowGraph = GetFlowGraph(flowchartContext); var flowScope = GetFlowScope(flowchartContext); - var completedActivityExecutedByBackwardConnection = completedActivityContext.ActivityInput.GetValueOrDefault(BackwardConnectionActivityInput); - var hasScheduledActivity = await ScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, completedActivity, outcomes, completedActivityExecutedByBackwardConnection); + var completedActivityExcecutedByBackwardConnection = completedActivityContext.ActivityInput.GetValueOrDefault(BackwardConnectionActivityInput); + bool hasScheduledActivity = await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, completedActivity, outcomes, OnChildCompletedAsync, completedActivityExcecutedByBackwardConnection); - // If there are not any outbound connections, complete the flowchart activity if there is no other pending work. + // If there are not any outbound connections, complete the flowchart activity if there is no other pending work if (!hasScheduledActivity) { await CompleteIfNoPendingWorkAsync(flowchartContext); @@ -186,19 +193,22 @@ public class Flowchart : Container /// /// Schedules outbound activities based on the flowchart's structure and execution state. /// This method determines whether an activity should be scheduled based on visited connections, - /// forward traversal rules, and backward connections. + /// forward traversal rules, and backward connections. If outcomes is Outcomes.Empty, it indicates + /// that the activity should be skipped - all outbound connections will be visited and treated as + /// not followed. /// - /// The flowchart containing the activities. /// The graph representation of the flowchart. /// Tracks activity and connection visits. /// The execution context of the flowchart. /// The current activity being processed. /// The outcomes that determine which connections were followed. - /// Indicates if the completed activity was executed due to a backward connection. + /// The callback to invoke upon activity completion. + /// Indicates if the completed activity + /// was executed due to a backward connection. /// True if at least one activity was scheduled; otherwise, false. - private async ValueTask ScheduleOutboundActivitiesAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity activity, Outcomes outcomes, bool completedActivityExecutedByBackwardConnection = false) + private static async ValueTask MaybeScheduleOutboundActivitiesAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity activity, Outcomes outcomes, ActivityCompletionCallback completionCallback, bool completedActivityExecutedByBackwardConnection = false) { - var hasScheduledActivity = false; + bool hasScheduledActivity = false; // Check if the activity is dangling (i.e., it is not reachable from the flowchart graph) if (flowGraph.IsDanglingActivity(activity)) @@ -211,47 +221,29 @@ public class Flowchart : Container { flowScope.RegisterActivityVisit(activity); } - - var outboundConnections = flowGraph.GetOutboundConnections(activity); - // Register the outbound connections as visited. - foreach (var outboundConnection in outboundConnections) + // Process each outbound connection from the current activity + foreach (var outboundConnection in flowGraph.GetOutboundConnections(activity)) { var connectionFollowed = outcomes.Names.Contains(outboundConnection.Source.Port); flowScope.RegisterConnectionVisit(outboundConnection, connectionFollowed); - } - - // Process each outbound connection from the current activity - foreach (var outboundConnection in outboundConnections) - { - var connectionFollowed = flowScope.GetConnectionLastVisitFollowed(outboundConnection); - - if(!connectionFollowed) - continue; // Skip if the connection was not followed. - var outboundActivity = outboundConnection.Target.Activity; // Determine the scheduling strategy based on connection-type. if (flowGraph.IsBackwardConnection(outboundConnection, out var backwardConnectionIsValid)) - { - hasScheduledActivity |= await ScheduleBackwardConnectionActivityAsync(flowGraph, flowchartContext, outboundConnection, outboundActivity, connectionFollowed, backwardConnectionIsValid); - } - else if (outboundActivity is not IJoinNode) - { - hasScheduledActivity |= await ScheduleNonJoinActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity); - } + // Backward connections are scheduled differently + hasScheduledActivity |= await MaybeScheduleBackwardConnectionActivityAsync(flowGraph, flowchartContext, outboundConnection, outboundActivity, connectionFollowed, backwardConnectionIsValid, completionCallback); else - { - hasScheduledActivity |= await ScheduleJoinActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity); - } + hasScheduledActivity |= await MaybeScheduleOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity, completionCallback); } + return hasScheduledActivity; } /// /// Schedules an outbound activity that originates from a backward connection. /// - private async ValueTask ScheduleBackwardConnectionActivityAsync(FlowGraph flowGraph, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, bool connectionFollowed, bool backwardConnectionIsValid) + private static async ValueTask MaybeScheduleBackwardConnectionActivityAsync(FlowGraph flowGraph, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, bool connectionFollowed, bool backwardConnectionIsValid, ActivityCompletionCallback completionCallback) { if (!connectionFollowed) { @@ -265,7 +257,7 @@ public class Flowchart : Container var scheduleWorkOptions = new ScheduleWorkOptions { - CompletionCallback = OnChildCompletedAsync, + CompletionCallback = completionCallback, Input = new Dictionary() { { BackwardConnectionActivityInput, true } } }; @@ -274,90 +266,130 @@ public class Flowchart : Container } /// - /// Schedules a non-join activity if all its forward inbound connections have been visited. + /// Determines the merge mode for a given outbound activity. If the outbound activity is a FlowJoin, it retrieves its configured + /// mode. Otherwise, it defaults to FlowJoinMode.WaitAllActive for implicit joins. /// - private async ValueTask ScheduleNonJoinActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity) + private static async ValueTask GetMergeModeAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity) { - if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + if (outboundActivity is FlowJoin) { - return false; + var outboundActivityExecutionContext = await flowchartContext.WorkflowExecutionContext.CreateActivityExecutionContextAsync(outboundActivity); + return await outboundActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode); } - - if (flowScope.HasFollowedInboundConnection(flowGraph, outboundActivity)) + else { - await flowchartContext.ScheduleActivityAsync(outboundActivity, OnChildCompletedAsync); - return true; + // Implicit join case - treat as WaitAllActive + return FlowJoinMode.WaitAllActive; } - - // Propagate skipped connections by scheduling with Outcomes.Empty - return await ScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, outboundActivity, Outcomes.Empty); } /// /// Schedules a join activity based on inbound connection statuses. /// - private async ValueTask ScheduleJoinActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity) + private static async ValueTask MaybeScheduleOutboundActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, ActivityCompletionCallback completionCallback) { - // Ignore the connection if the join activity has already completed (JoinAny scenario) - if (flowScope.ShouldIgnoreConnection(outboundConnection, outboundActivity)) - { - return false; - } + FlowJoinMode mode = await GetMergeModeAsync(flowchartContext, outboundActivity); - // Schedule the join activity only if at least one inbound connection was followed - if (!flowScope.HasFollowedInboundConnection(flowGraph, outboundActivity)) + return mode switch { - if (flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) - { - // Propagate skipped connections by scheduling with Outcomes.Empty - return await ScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, outboundActivity, Outcomes.Empty); - } - return false; - } - - // Check for an existing execution context for the join activity - var joinContext = flowchartContext.WorkflowExecutionContext.ActivityExecutionContexts.LastOrDefault(x => - x.ParentActivityExecutionContext == flowchartContext && - x.Activity == outboundActivity && - x.Status is ActivityStatus.Pending or ActivityStatus.Running); - - // If the join activity was already scheduled, do not schedule it again - if (joinContext == null) - { - var activityScheduled = flowchartContext.WorkflowExecutionContext.Scheduler.List().Any(workItem => workItem.Owner == flowchartContext && workItem.Activity == outboundActivity); - if (activityScheduled) - { - return true; - } - } - - var scheduleWorkOptions = new ScheduleWorkOptions - { - CompletionCallback = OnChildCompletedAsync, - ExistingActivityExecutionContext = joinContext + FlowJoinMode.WaitAll => await MaybeScheduleWaitAllActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback), + FlowJoinMode.WaitAllActive => await MaybeScheduleWaitAllActiveActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback), + FlowJoinMode.WaitAny => await MaybeScheduleWaitAnyActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity, completionCallback), + _ => throw new($"Unsupported FlowJoinMode: {mode}"), }; - await flowchartContext.ScheduleActivityAsync(outboundActivity, scheduleWorkOptions); + } + + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAll behavior. + /// If all inbound connections were visited, it checks if they were all followed to decide whether to schedule or skip the activity. + /// + private static async ValueTask MaybeScheduleWaitAllActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // Not all inbound connections have been visited yet; do not schedule anything yet. + return false; + + if (flowScope.AllInboundConnectionsFollowed(flowGraph, outboundActivity)) + // All inbound connections were followed; schedule the outbound activity. + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + else + // No inbound connections were followed; skip the outbound activity. + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + } + + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAllActive behavior. + /// If all inbound connections have been visited, it checks if any were followed to decide whether to schedule or skip the activity. + /// + private static async ValueTask MaybeScheduleWaitAllActiveActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // Not all inbound connections have been visited yet; do not schedule anything yet. + return false; + + if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) + // At least one inbound connection was followed; schedule the outbound activity. + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + else + // No inbound connections were followed; skip the outbound activity. + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + } + + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAny behavior. + /// If any inbound connection has been followed, it schedules the activity and cancels remaining inbound activities. + /// If a subsequent inbound connection is followed after the activity has been scheduled, it ignores it. + /// + private static async ValueTask MaybeScheduleWaitAnyActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + if (flowScope.ShouldIgnoreConnection(outboundConnection, outboundActivity)) + // Ignore the connection if the outbound activity has already completed (JoinAny scenario) + return false; + + if (flowchartContext.WorkflowExecutionContext.Scheduler.List().Any(workItem => workItem.Owner == flowchartContext && workItem.Activity == outboundActivity)) + // Ignore the connection if the outbound activity is already scheduled + return false; + + if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) + { + // An inbound connection has been followed; cancel remaining inbound activities + await CancelRemainingInboundActivitiesAsync(flowchartContext, outboundActivity); + + // This is the first inbound connection followed; schedule the outbound activity + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + } + + if (flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // All inbound connections have been visited without any being followed; skip the outbound activity + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + + // No inbound connections have been followed yet; do not schedule anything yet. + return false; + } + + /// + /// Schedules the outbound activity. + /// + private static async ValueTask ScheduleOutboundActivityAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + await flowchartContext.ScheduleActivityAsync(outboundActivity, completionCallback); return true; } - public static bool CanWaitAllProceed(ActivityExecutionContext context) + /// + /// Skips the outbound activity by propagating skipped connections. + /// + private static async ValueTask SkipOutboundActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) { - var flowchartContext = context.ParentActivityExecutionContext!; - var flowchart = (Flowchart)flowchartContext.Activity; - var flowGraph = flowchart.GetFlowGraph(flowchartContext); - var flowScope = flowchart.GetFlowScope(flowchartContext); - var activity = context.Activity; - - return flowScope.AllInboundConnectionsVisited(flowGraph, activity); + return await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, outboundActivity, Outcomes.Empty, completionCallback); } - public static async Task CancelAncestorActivitiesAsync(ActivityExecutionContext context) + private static async ValueTask CancelRemainingInboundActivitiesAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity) { - var flowchartContext = context.ParentActivityExecutionContext!; var flowchart = (Flowchart)flowchartContext.Activity; var flowGraph = flowchart.GetFlowGraph(flowchartContext); - var ancestorActivities = flowGraph.GetAncestorActivities(context.Activity); - var inboundActivityExecutionContexts = context.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == flowchartContext).ToList(); + var ancestorActivities = flowGraph.GetAncestorActivities(outboundActivity); + var inboundActivityExecutionContexts = flowchartContext.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == flowchartContext).ToList(); // Cancel each ancestor activity. foreach (var activityExecutionContext in inboundActivityExecutionContexts) @@ -386,15 +418,9 @@ public class Flowchart : Container var flowchartContext = context.ReceiverActivityExecutionContext; var schedulingActivityContext = context.SenderActivityExecutionContext; var schedulingActivity = schedulingActivityContext.Activity; - var outcomes = signal.Outcomes; - var outboundConnections = Connections.Where(connection => connection.Source.Activity == schedulingActivity && outcomes.Contains(connection.Source.Port!)).ToList(); - var outboundActivities = outboundConnections.Select(x => x.Target.Activity).ToList(); + var outcomes = new Outcomes(signal.Outcomes); - if (outboundActivities.Any()) - { - // Schedule each child. - foreach (var activity in outboundActivities) await flowchartContext.ScheduleActivityAsync(activity, OnChildCompletedAsync); - } + await ProcessChildCompletedAsync(flowchartContext, schedulingActivity, schedulingActivityContext, outcomes); } private async ValueTask OnScheduleChildActivityAsync(ScheduleChildActivity signal, SignalContext context) diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowJoinMode.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowJoinMode.cs index cd23b523b..7372af7c8 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowJoinMode.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowJoinMode.cs @@ -3,5 +3,6 @@ namespace Elsa.Workflows.Activities.Flowchart.Models; public enum FlowJoinMode { WaitAll, - WaitAny + WaitAllActive, + WaitAny, } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowScope.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowScope.cs index 742fd3222..8d0155fc1 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowScope.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowScope.cs @@ -86,7 +86,7 @@ public class FlowScope /// The flow graph containing connections. /// The activity to check. /// True if any inbound connection has been followed, otherwise false. - public bool HasFollowedInboundConnection(FlowGraph flowGraph, IActivity activity) + public bool AnyInboundConnectionsFollowed(FlowGraph flowGraph, IActivity activity) { var forwardInboundConnections = flowGraph.GetForwardInboundConnections(activity); var outboundActivityVisitCount = GetActivityVisitCount(activity); @@ -95,6 +95,21 @@ public class FlowScope && forwardInboundConnections.Any(c => GetConnectionVisitCount(c) == maxConnectionVisitCount && GetConnectionLastVisitFollowed(c)); } + /// + /// Determines whether all inbound connection to the specified activity has been followed. + /// + /// The flow graph containing connections. + /// The activity to check. + /// True if all inbound connection has been followed, otherwise false. + public bool AllInboundConnectionsFollowed(FlowGraph flowGraph, IActivity activity) + { + var forwardInboundConnections = flowGraph.GetForwardInboundConnections(activity); + var outboundActivityVisitCount = GetActivityVisitCount(activity); + var maxConnectionVisitCount = forwardInboundConnections.Max(GetConnectionVisitCount); + return maxConnectionVisitCount > outboundActivityVisitCount + && forwardInboundConnections.All(c => GetConnectionVisitCount(c) == maxConnectionVisitCount && GetConnectionLastVisitFollowed(c)); + } + /// /// Determines whether a connection should be ignored based on visit counts. /// diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs index 460c2d2a8..cc0363df2 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs @@ -195,8 +195,9 @@ public class FlowchartNextActivityTests [Theory(DisplayName = "Flowchart with a Join activity executed multiple times")] [InlineData(FlowJoinMode.WaitAll)] + [InlineData(FlowJoinMode.WaitAllActive)] [InlineData(FlowJoinMode.WaitAny)] - public async Task WaitAnyLoopTest(FlowJoinMode joinMode) + public async Task JoinLoopTest(FlowJoinMode joinMode) { var workflow = new TestWorkflow(workflowBuilder => { @@ -274,8 +275,9 @@ public class FlowchartNextActivityTests [Theory(DisplayName = "Flowchart with a Join activity executed multiple times, bug 6479")] [InlineData(FlowJoinMode.WaitAll)] + [InlineData(FlowJoinMode.WaitAllActive)] [InlineData(FlowJoinMode.WaitAny)] - public async Task WaitLoopBug6479Test(FlowJoinMode joinMode) + public async Task JoinLoopBug6479Test(FlowJoinMode joinMode) { var workflow = new TestWorkflow(workflowBuilder => { @@ -343,4 +345,87 @@ public class FlowchartNextActivityTests "A", "A", "A", "B" }, lines); } + + [Theory(DisplayName = "Flowchart Join behaves correctly")] + [InlineData(false, FlowJoinMode.WaitAll, new[] { "A", "B", "C", "D", "F" })] // "E" is not scheduled because join has an unfollowed inbound connection + [InlineData(false, FlowJoinMode.WaitAllActive, new[] { "A", "B", "C", "D", "E", "F" })] // "E" gets scheduled by join with an unfollowed inbound connection + [InlineData(false, FlowJoinMode.WaitAny, new[] { "A", "B", "C", "D", "E", "F" })] // "E" only scheduled once + [InlineData(true, FlowJoinMode.WaitAll, new[] { "A", "B", "C", "E", "F" })] // all Join inbound connections followed, "E" gets scheduled + [InlineData(true, FlowJoinMode.WaitAllActive, new[] { "A", "B", "C", "E", "F" })] // all Join inbound connections followed, "E" gets scheduled + [InlineData(true, FlowJoinMode.WaitAny, new[] { "A", "B", "C", "E", "F" })] // "E" only scheduled once + // Start + // / | \ + // / | \ + // A B C + // / | | + // | | | + // Decision | | + // / \ | | + // | (true) | / + // | \ | / + // (false) \ | / + // | Join + // D | + // \ E + // \ / + // \ / + // \ / + // F + public async Task JoinBehavesCorrectly(bool decisionResult, FlowJoinMode joinMode, string[] expectedLines) + { + 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 decision = new FlowDecision() + { + Condition = new(new Literal(decisionResult)) + }; + var d = new WriteLine("D") { Id = "WriteLineD" }; + var join = new FlowJoin() + { + Mode = new(joinMode) + }; + var e = new WriteLine("E") { Id = "WriteLineE" }; + var f = new WriteLine("F") { Id = "WriteLineF" }; + + workflowBuilder.Root = new Flowchart + { + Activities = + { + start, + a, + b, + c, + decision, + d, + join, + e, + f, + }, + Connections = + { + new(start, a), + new(start, b), + new(start, c), + new(a, decision), + new(new Endpoint(decision, "True"), new Endpoint(join)), + new(new Endpoint(decision, "False"), new Endpoint(d)), + new(b, join), + new(c, join), + new(d, f), + new(join, e), + new(e, f), + } + }; + }); + + await _services.PopulateRegistriesAsync(); + var result = await _workflowRunner.RunAsync(workflow); + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + Assert.Equal(expectedLines, lines); + } } \ No newline at end of file From 485349b2622e6b7fd05c576994cf871b6616cfc6 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 11 Nov 2025 17:35:32 +0100 Subject: [PATCH 02/27] Clarify `FlowJoin` activity description to specify handling of active inbound paths. --- .../Activities/Flowchart/Activities/FlowJoin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs index a383e3598..cea6f8299 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs @@ -24,7 +24,7 @@ public class FlowJoin : Activity, IJoinNode /// The join mode determines whether this activity should continue as soon as one inbound path comes in (Wait Any), or once all inbound paths have executed (Wait All). /// [Input( - Description = "The join mode determines whether this activity should continue as soon as one inbound path comes in (Wait Any), or once all inbound paths have executed (Wait All).", + Description = "The join mode determines whether this activity should continue as soon as one inbound path comes in (WaitAny), or once all inbound paths have executed (WaitAll). To wait for all activated inbound paths, set the mode to WaitAllActive.", DefaultValue = FlowJoinMode.WaitAny, UIHint = InputUIHints.DropDown )] From f37d368cc629fca3691f507eb24c0248d039c9a0 Mon Sep 17 00:00:00 2001 From: bobhauser Date: Tue, 11 Nov 2025 13:07:01 -0500 Subject: [PATCH 03/27] Simplify registration of IWorkflowResumer (#6983) Co-authored-by: Bob Hauser --- .../Features/WorkflowRuntimeFeature.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index ccde9984f..578dc3010 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -117,8 +117,16 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) /// /// A factory that instantiates an . /// - public Func DispatchWorkflowCommandHandler { get; set; } = sp => sp.GetRequiredService(); - + public Func DispatchWorkflowCommandHandler { get; set; } = sp => sp.GetRequiredService(); + + /// + /// A factory that instantiates an . + /// + public Func WorkflowResumer { get; set; } = sp => sp.GetRequiredService(); + + /// + /// A factory that instantiates an . + /// public Func BookmarkQueueWorker { get; set; } = sp => sp.GetRequiredService(); /// @@ -250,7 +258,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddSingleton(BackgroundActivityScheduler) .AddSingleton() .AddSingleton() - .AddScoped() + .AddScoped(BookmarkQueueWorker) .AddScoped() .AddScoped() .AddScoped() @@ -276,7 +284,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped() .AddScoped() - .AddScoped() + .AddScoped(WorkflowResumer) .AddScoped() .AddScoped() .AddScoped() From 30c1f16c15a3a7687e0aff80b81a4649095a7df7 Mon Sep 17 00:00:00 2001 From: RalfvandenBurg Date: Tue, 11 Nov 2025 19:10:03 +0100 Subject: [PATCH 04/27] Explicit state Bookmarkname (#6963) * Explicit stat5e Bookmarkname * Removed dutch comment * compile fixes * Update src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Ralf Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/Elsa.Http/Activities/HttpEndpoint.cs | 2 +- .../Elsa.Http/Activities/HttpEndpointBase.cs | 4 ++-- ...ttpEndpointActivityExecutionContextExtensions.cs | 13 +++++++------ .../Contexts/ActivityExecutionContext.cs | 4 +++- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index 6db32f823..7618ab39b 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -163,7 +163,7 @@ public class HttpEndpoint : Trigger { var path = Path.Get(context); var methods = SupportedMethods.GetOrDefault(context) ?? new List { HttpMethods.Get }; - await context.WaitForHttpRequestAsync(path, methods, OnResumeAsync); + await context.WaitForHttpRequestAsync(path, methods, OnResumeAsync, Elsa.Http.HttpStimulusNames.HttpEndpoint); } private async ValueTask OnResumeAsync(ActivityExecutionContext context) diff --git a/src/modules/Elsa.Http/Activities/HttpEndpointBase.cs b/src/modules/Elsa.Http/Activities/HttpEndpointBase.cs index 9257a73c1..8d6a708ce 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpointBase.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpointBase.cs @@ -22,8 +22,8 @@ public abstract class HttpEndpointBase : Trigger protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { - var options = GetOptions(); - await context.WaitForHttpRequestAsync(options, HttpRequestReceivedAsync); + var options = GetOptions(); + await context.WaitForHttpRequestAsync(options, HttpRequestReceivedAsync, Elsa.Http.HttpStimulusNames.HttpEndpoint); } protected override IEnumerable GetTriggerPayloads(TriggerIndexingContext context) diff --git a/src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs b/src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs index ca62a0782..4c56c3cd3 100644 --- a/src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs @@ -9,27 +9,27 @@ namespace Elsa.Http.Extensions; public static class HttpEndpointActivityExecutionContextExtensions { -public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, string path, string method, ExecuteActivityDelegate? callback = null) +public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, string path, string method, ExecuteActivityDelegate? callback = null, string? bookmarkName = null) { var options = new HttpEndpointOptions { Path = path, Methods = [method] }; - await WaitForHttpRequestAsync(context, options, callback); + await WaitForHttpRequestAsync(context, options, callback, bookmarkName); } -public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, string path, IEnumerable methods, ExecuteActivityDelegate? callback = null) +public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, string path, IEnumerable methods, ExecuteActivityDelegate? callback = null, string? bookmarkName = null) { var options = new HttpEndpointOptions { Path = path, Methods = methods.ToList() }; - await WaitForHttpRequestAsync(context, options, callback); + await WaitForHttpRequestAsync(context, options, callback, bookmarkName); } - public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, HttpEndpointOptions options, ExecuteActivityDelegate? callback = null) + public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, HttpEndpointOptions options, ExecuteActivityDelegate? callback = null, string? bookmarkName = null) { var path = options.Path; if (path.Contains("//")) @@ -38,7 +38,8 @@ public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionCont var expressionExecutionContext = context.ExpressionExecutionContext; if (!context.IsTriggerOfWorkflow()) { - context.CreateBookmarks(expressionExecutionContext.GetHttpEndpointStimuli(options), includeActivityInstanceId: false, callback: callback); + var name = bookmarkName ?? Elsa.Http.HttpStimulusNames.HttpEndpoint; + context.CreateBookmarks(expressionExecutionContext.GetHttpEndpointStimuli(options), includeActivityInstanceId: false, bookmarkName: name, callback: callback); return; } diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index 10a123e64..fea34ac25 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -418,13 +418,15 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// The payloads to create bookmarks for. /// An optional callback that is invoked when the bookmark is resumed. /// Whether or not the activity instance ID should be included in the bookmark payload. - public void CreateBookmarks(IEnumerable payloads, ExecuteActivityDelegate? callback = null, bool includeActivityInstanceId = true) + /// An optional name to use for the bookmark. Defaults to the activity type. + public void CreateBookmarks(IEnumerable payloads, ExecuteActivityDelegate? callback = null, bool includeActivityInstanceId = true, string? bookmarkName = null) { foreach (var payload in payloads) CreateBookmark(new() { Stimulus = payload, Callback = callback, + BookmarkName = bookmarkName, IncludeActivityInstanceId = includeActivityInstanceId }); } From d84e97945cedc736f20ce1ca53fbed171581f011 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 12 Nov 2025 17:09:13 +0100 Subject: [PATCH 05/27] Add integration test for `newGuid()` in JavaScript evaluator. Introduces a new unit test in `Elsa.JavaScript.IntegrationTests` to verify that the `newGuid()` function in the JavaScript evaluator correctly returns a `Guid` type. --- .../GuidTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs new file mode 100644 index 000000000..a46e1aa4b --- /dev/null +++ b/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs @@ -0,0 +1,33 @@ +using Elsa.Expressions.Models; +using Elsa.JavaScript.Contracts; +using Elsa.Testing.Shared; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.JavaScript.IntegrationTests; + +public class GuidTests +{ + private readonly IJavaScriptEvaluator _evaluator; + private readonly IServiceProvider _serviceProvider; + + public GuidTests(ITestOutputHelper testOutputHelper) + { + _serviceProvider = new TestApplicationBuilder(testOutputHelper).Build(); + _evaluator = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task NewGuidReturnsGuid() + { + //Setup + var script = "newGuid()"; + var expressionExecutionContext = new ExpressionExecutionContext(_serviceProvider, new()); + + //Act + var result = (Guid)(await _evaluator.EvaluateAsync(script, typeof(Guid), expressionExecutionContext))!; + + //Assert + Assert.IsType(result); + } +} \ No newline at end of file From b9360d3b5fba9bad78fac60c73e0048ba5daecf9 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 12 Nov 2025 17:18:38 +0100 Subject: [PATCH 06/27] Add missing service registration for `WorkflowResumer`. --- .../Features/WorkflowRuntimeFeature.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 578dc3010..3d287ea5f 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -117,15 +117,15 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) /// /// A factory that instantiates an . /// - public Func DispatchWorkflowCommandHandler { get; set; } = sp => sp.GetRequiredService(); - - /// - /// A factory that instantiates an . - /// + public Func DispatchWorkflowCommandHandler { get; set; } = sp => sp.GetRequiredService(); + + /// + /// A factory that instantiates an . + /// public Func WorkflowResumer { get; set; } = sp => sp.GetRequiredService(); - - /// - /// A factory that instantiates an . + + /// + /// A factory that instantiates an . /// public Func BookmarkQueueWorker { get; set; } = sp => sp.GetRequiredService(); @@ -285,6 +285,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped() .AddScoped(WorkflowResumer) + .AddScoped() .AddScoped() .AddScoped() .AddScoped() From 2f135f3e614f542c9771a3563d9c2091d66a72f5 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 12 Nov 2025 22:55:22 +0100 Subject: [PATCH 07/27] Add service registration for `BookmarkQueueWorker`. --- .../Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 3d287ea5f..cae1f3a76 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -286,6 +286,8 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped(WorkflowResumer) .AddScoped() + .AddScoped(BookmarkQueueWorker) + .AddScoped() .AddScoped() .AddScoped() .AddScoped() From 0f4e5c6f80a4eb75c0fbe27c2cf08cbfdce9f0da Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 17 Nov 2025 21:19:32 +0100 Subject: [PATCH 08/27] Refactors ExpressionExecutionContextExtensions for improved debugging (#7070) * Refactor `ExpressionExecutionContextExtensions` to convert static methods into extension methods and remove unused imports. * Refactor `ExpressionExecutionContextExtensions` to convert instance methods into extension methods. --- .../ExpressionExecutionContextExtensions.cs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs index aba7ae1ac..1deb59793 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs @@ -8,9 +8,7 @@ using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; -using Elsa.Workflows.Options; using Humanizer; -using Microsoft.Extensions.Options; // ReSharper disable once CheckNamespace namespace Elsa.Extensions; @@ -74,12 +72,22 @@ public static class ExpressionExecutionContextExtensions /// /// Returns the of the specified /// - public static WorkflowExecutionContext GetWorkflowExecutionContext(this ExpressionExecutionContext context) => (WorkflowExecutionContext)context.TransientProperties[WorkflowExecutionContextKey]; + public static WorkflowExecutionContext GetWorkflowExecutionContext(this ExpressionExecutionContext context) + { + return context.TransientProperties.TryGetValue(WorkflowExecutionContextKey, out var value) + ? (WorkflowExecutionContext)value + : throw new InvalidOperationException("WorkflowExecutionContext not found. This value exists only on activity execution contexts."); + } /// /// Returns the of the specified /// - public static ActivityExecutionContext GetActivityExecutionContext(this ExpressionExecutionContext context) => (ActivityExecutionContext)context.TransientProperties[ActivityExecutionContextKey]; + public static ActivityExecutionContext GetActivityExecutionContext(this ExpressionExecutionContext context) + { + return context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out var value) + ? (ActivityExecutionContext)value + : throw new InvalidOperationException("ActivityExecutionContext not found. This value exists only on activity execution contexts."); + } /// /// Returns the of the specified @@ -89,7 +97,12 @@ public static class ExpressionExecutionContextExtensions /// /// Returns the of the specified /// - public static IActivity GetActivity(this ExpressionExecutionContext context) => (IActivity)context.TransientProperties[ActivityKey]; + public static IActivity GetActivity(this ExpressionExecutionContext context) + { + return context.TransientProperties.TryGetValue(ActivityKey, out var value) + ? (IActivity)value + : throw new InvalidOperationException("Activity not found. This value exists only on activity execution contexts."); + } /// /// Returns the value of the specified input. From de1fd496cbe470afaecb38a97387249ad73dc26f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 17 Nov 2025 22:30:49 +0100 Subject: [PATCH 09/27] Refactor `ProtoActorWorkflowClient` to centralize cluster readiness check and streamline client operations with `ExecuteAsync` methods. (#7071) --- .../Services/ProtoActorWorkflowClient.cs | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Services/ProtoActorWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Services/ProtoActorWorkflowClient.cs index a37cb11b3..9cb4dee2d 100644 --- a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Services/ProtoActorWorkflowClient.cs +++ b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Services/ProtoActorWorkflowClient.cs @@ -19,6 +19,7 @@ namespace Elsa.Workflows.Runtime.ProtoActor.Services; [UsedImplicitly] public class ProtoActorWorkflowClient : IWorkflowClient { + private readonly Cluster _cluster; private readonly Mappers.Mappers _mappers; private readonly ITenantAccessor _tenantAccessor; private readonly WorkflowInstanceClient _actorClient; @@ -29,6 +30,7 @@ public class ProtoActorWorkflowClient : IWorkflowClient public ProtoActorWorkflowClient(string workflowInstanceId, Cluster cluster, Mappers.Mappers mappers, ITenantAccessor tenantAccessor, IWorkflowActivationStrategyEvaluator workflowActivationStrategyEvaluator) { WorkflowInstanceId = workflowInstanceId; + _cluster = cluster; _mappers = mappers; _tenantAccessor = tenantAccessor; _actorClient = cluster.GetNamedWorkflowInstanceClient(WorkflowInstanceId); @@ -41,7 +43,7 @@ public class ProtoActorWorkflowClient : IWorkflowClient public async Task CreateInstanceAsync(CreateWorkflowInstanceRequest request, CancellationToken cancellationToken = default) { var protoRequest = _mappers.CreateWorkflowInstanceRequestMapper.Map(WorkflowInstanceId, request); - var response = await _actorClient.Create(protoRequest, CreateHeaders(), cancellationToken); + var response = await ExecuteAsync(client => client.Create(protoRequest, CreateHeaders(), cancellationToken)); return _mappers.CreateWorkflowInstanceResponseMapper.Map(response!); } @@ -49,7 +51,7 @@ public class ProtoActorWorkflowClient : IWorkflowClient public async Task RunInstanceAsync(RunWorkflowInstanceRequest request, CancellationToken cancellationToken = default) { var protoRequest = _mappers.RunWorkflowInstanceRequestMapper.Map(request); - var response = await _actorClient.Run(protoRequest, CreateHeaders(), cancellationToken); + var response = await ExecuteAsync(client => client.Run(protoRequest, CreateHeaders(), cancellationToken)); return _mappers.RunWorkflowInstanceResponseMapper.Map(WorkflowInstanceId, response!); } @@ -57,20 +59,20 @@ public class ProtoActorWorkflowClient : IWorkflowClient public async Task CreateAndRunInstanceAsync(CreateAndRunWorkflowInstanceRequest request, CancellationToken cancellationToken = default) { var protoRequest = _mappers.CreateAndRunWorkflowInstanceRequestMapper.Map(WorkflowInstanceId, request); - var response = await _actorClient.CreateAndRun(protoRequest, CreateHeaders(), cancellationToken); + var response = await ExecuteAsync(client => client.CreateAndRun(protoRequest, CreateHeaders(), cancellationToken)); return _mappers.RunWorkflowInstanceResponseMapper.Map(WorkflowInstanceId, response!); } /// public async Task CancelAsync(CancellationToken cancellationToken = default) { - await _actorClient.Cancel(CreateHeaders(), cancellationToken); + await ExecuteAsync(client => client.Cancel(CreateHeaders(), cancellationToken)); } /// public async Task ExportStateAsync(CancellationToken cancellationToken = default) { - var response = await _actorClient.ExportState(CreateHeaders(), cancellationToken); + var response = await ExecuteAsync(client => client.ExportState(CreateHeaders(), cancellationToken)); return _mappers.WorkflowStateJsonMapper.Map(response!.SerializedWorkflowState); } @@ -82,7 +84,7 @@ public class ProtoActorWorkflowClient : IWorkflowClient { SerializedWorkflowState = protoJson }; - await _actorClient.ImportState(request, CreateHeaders(), cancellationToken); + await ExecuteAsync(client => client.ImportState(request, CreateHeaders(), cancellationToken)); } public Task InstanceExistsAsync(CancellationToken cancellationToken = default) @@ -90,6 +92,18 @@ public class ProtoActorWorkflowClient : IWorkflowClient throw new NotImplementedException(); } + private async Task ExecuteAsync(Func action) + { + await _cluster.JoinedCluster; + await action(_actorClient); + } + + private async Task ExecuteAsync(Func> action) + { + await _cluster.JoinedCluster; + return await action(_actorClient); + } + private IDictionary CreateHeaders() { var headers = new Dictionary(); From 4e58970a299cea1941661588660224129ca9e450 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 18 Nov 2025 10:26:16 +0100 Subject: [PATCH 10/27] Update `ElsaStudioVersion` to `3.5.1-preview.1276` in `Directory.Packages.props`. --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index afb7bfd09..0bf216921 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ true - 3.5.1-preview.1231 + 3.5.1-preview.1276 3.5.1-preview.148 9.0.9 From 776df847795ae2fb3d4cac096f5087bd0783210d Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 19 Nov 2025 20:18:16 +0100 Subject: [PATCH 11/27] Update workflow to reference `3.5.3` base version. --- .github/workflows/packages.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index cb2df493f..db8cdba76 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -15,7 +15,7 @@ on: release: types: [ prereleased, published ] env: - base_version: '3.5.2' + base_version: '3.5.3' feedz_feed_source: 'https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json' nuget_feed_source: 'https://api.nuget.org/v3/index.json' @@ -48,7 +48,7 @@ jobs: run: | if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased")]]; then git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* - git branch --remote --contains | grep origin/patch/3.5.2 + git branch --remote --contains | grep origin/patch/3.5.3 else git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* git branch --remote --contains | grep origin/${BRANCH_NAME} From 9579a552adfc0f24b554c0d2d8a6e1ff7e01f009 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 9 Dec 2025 22:12:30 +0100 Subject: [PATCH 12/27] Downgrade JetBrains.Annotations package to version 2024.3.0. This is a test to see if this works around an issue where the GitHub Actions CI server runs until its timeout of 30 minutes. --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f3f3e88af..d7257ac89 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,7 +69,7 @@ - + From 133c8b2394e72b5166b05257944b9c334430d31f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 09:05:48 +0100 Subject: [PATCH 13/27] Revert "Downgrade JetBrains.Annotations package to version 2024.3.0." This reverts commit 9579a552adfc0f24b554c0d2d8a6e1ff7e01f009. --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7a8a25894..ecf434e00 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -109,7 +109,7 @@ - + From baf2495fd5cb0d4999838475d42710f5c193f50f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 09:22:37 +0100 Subject: [PATCH 14/27] Update .NET package versions to 10.0.1 for compatibility and stability improvements Fix `Npgsql.EntityFrameworkCore.PostgreSQL` to reference version 10.0.0 in `Directory.Packages.props`. --- Directory.Packages.props | 72 ++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ecf434e00..4a0ce735c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -43,38 +43,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -126,7 +126,7 @@ - + @@ -151,18 +151,18 @@ - + - + - + From 56089c95f16aafb32c70a555f3377361caff9fb5 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 10:55:52 +0100 Subject: [PATCH 15/27] Update workflow to dump Docker logs on failure and improve PostgreSQL container configuration - Add Docker logs capturing step in GitHub Actions workflow for better debugging. - Update PostgreSQL test container to use `postgres:16-alpine` with enhanced configuration options. - Set database connection `Max Pool Size` to 20 in component tests. --- .github/workflows/packages.yml | 11 +++++++++++ .../Helpers/Fixtures/Infrastructure.cs | 9 ++++++++- .../Helpers/Fixtures/WorkflowServer.cs | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 02d18e5db..14458a322 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -70,6 +70,17 @@ jobs: dotnet test "$project" --configuration Release --no-build --logger "GitHubActions;report-warnings=false" /p:CollectCoverage=true done + - name: Dump docker logs on failure + if: failure() + run: | + echo "=== Docker containers ===" + docker ps -a || true + echo "=== Docker logs ===" + for container in $(docker ps -aq); do + echo "--- Logs for container $container ---" + docker logs "$container" 2>&1 | tail -100 || true + done + - name: Install ReportGenerator run: dotnet tool install -g dotnet-reportgenerator-globaltool diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs index 497951b60..3e59de4c8 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs @@ -6,10 +6,17 @@ namespace Elsa.Workflows.ComponentTests.Fixtures; public class Infrastructure : IAsyncLifetime { public readonly PostgreSqlContainer DbContainer = new PostgreSqlBuilder() - .WithImage("postgres:latest") + .WithImage("postgres:16-alpine") .WithDatabase("elsa") .WithUsername("postgres") .WithPassword("postgres") + .WithCommand( + "postgres", + "-c", "max_connections=25", + "-c", "shared_buffers=128MB", + "-c", "work_mem=4MB", + "-c", "effective_cache_size=256MB" + ) .Build(); public readonly RabbitMqContainer RabbitMqContainer = new RabbitMqBuilder() diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index aeae8967e..0a1088115 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -55,7 +55,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl protected override void ConfigureWebHost(IWebHostBuilder builder) { - var dbConnectionString = infrastructure.DbContainer.GetConnectionString(); + var dbConnectionString = infrastructure.DbContainer.GetConnectionString() + ";Max Pool Size=20;"; var rabbitMqConnectionString = infrastructure.RabbitMqContainer.GetConnectionString(); builder.UseUrls(url); From bcb613cf86db3d009f1b05c88cd36858a63b19e2 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 11:01:52 +0100 Subject: [PATCH 16/27] Remove redundant database configuration and simplify connection string in WorkflowServer. --- .../Helpers/Fixtures/Infrastructure.cs | 1 - .../Helpers/Fixtures/WorkflowServer.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs index 3e59de4c8..65ecb1524 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs @@ -11,7 +11,6 @@ public class Infrastructure : IAsyncLifetime .WithUsername("postgres") .WithPassword("postgres") .WithCommand( - "postgres", "-c", "max_connections=25", "-c", "shared_buffers=128MB", "-c", "work_mem=4MB", diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 0a1088115..aeae8967e 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -55,7 +55,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl protected override void ConfigureWebHost(IWebHostBuilder builder) { - var dbConnectionString = infrastructure.DbContainer.GetConnectionString() + ";Max Pool Size=20;"; + var dbConnectionString = infrastructure.DbContainer.GetConnectionString(); var rabbitMqConnectionString = infrastructure.RabbitMqContainer.GetConnectionString(); builder.UseUrls(url); From da88712e753ca52e9a6cf45fe3d051df8a556514 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 11:10:45 +0100 Subject: [PATCH 17/27] Increase PostgreSQL test container max connections to 100 for improved parallelism in component tests --- .../Helpers/Fixtures/Infrastructure.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs index 65ecb1524..49f1e1382 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs @@ -11,7 +11,7 @@ public class Infrastructure : IAsyncLifetime .WithUsername("postgres") .WithPassword("postgres") .WithCommand( - "-c", "max_connections=25", + "-c", "max_connections=100", "-c", "shared_buffers=128MB", "-c", "work_mem=4MB", "-c", "effective_cache_size=256MB" From 8730f0cdeb67c17ec4ffe29bf792b75a277f7812 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 11:25:17 +0100 Subject: [PATCH 18/27] Restore `Scope.Dispose` in AppComponentTest and update WorkflowServer DB connection string with pool size configuration --- .../Helpers/Abstractions/AppComponentTest.cs | 2 +- .../Helpers/Fixtures/WorkflowServer.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs index b0db161d7..ef2baef4e 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs @@ -15,7 +15,7 @@ public abstract class AppComponentTest(App app) : IDisposable { // Disposing the Scope here and in other places where it is created somehow seems to cause the test runner to hang when running other test projects. // Let's comment it out for the time being. - //Scope.Dispose(); + Scope.Dispose(); OnDispose(); } diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index aeae8967e..3c590c0e0 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -55,7 +55,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl protected override void ConfigureWebHost(IWebHostBuilder builder) { - var dbConnectionString = infrastructure.DbContainer.GetConnectionString(); + var dbConnectionString = infrastructure.DbContainer.GetConnectionString() + ";Minimum Pool Size=0;Maximum Pool Size=10"; var rabbitMqConnectionString = infrastructure.RabbitMqContainer.GetConnectionString(); builder.UseUrls(url); From c79275fccabb93a5e37559ecccb1447bc0a1fea5 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 11:25:29 +0100 Subject: [PATCH 19/27] Restore `Scope.Dispose` in `AppComponentTest` to ensure proper resource cleanup during test execution. --- .../Helpers/Abstractions/AppComponentTest.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs index ef2baef4e..d69ed62f1 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs @@ -13,8 +13,6 @@ public abstract class AppComponentTest(App app) : IDisposable void IDisposable.Dispose() { - // Disposing the Scope here and in other places where it is created somehow seems to cause the test runner to hang when running other test projects. - // Let's comment it out for the time being. Scope.Dispose(); OnDispose(); } From a80490101a508f8b45e65badf46f89b68aae007b Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 21:34:00 +0100 Subject: [PATCH 20/27] Refactor flowchart activity execution logic to improve modularity and add enhanced support for FlowJoin activity types. --- .../Flowchart/Activities/FlowJoin.cs | 1 + .../Activities/Flowchart.Counters.cs | 350 ++++++++++------ .../Flowchart/Activities/Flowchart.cs | 387 +----------------- .../ExpressionExecutionContextExtensions.cs | 65 ++- .../GuidTests.cs | 2 +- .../Scenarios/FlowchartNextActivity/Tests.cs | 2 + 6 files changed, 277 insertions(+), 530 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs index c83c5a74b..e1abb04de 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using Elsa.Workflows.Activities.Flowchart.Contracts; +using Elsa.Workflows.Activities.Flowchart.Extensions; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Attributes; using Elsa.Workflows.Models; diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs index 7e0194259..06f3ec9df 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs @@ -10,8 +10,10 @@ namespace Elsa.Workflows.Activities.Flowchart.Activities; public partial class Flowchart { private const string ScopeProperty = "FlowScope"; + private const string GraphTransientProperty = "FlowGraph"; private const string BackwardConnectionActivityInput = "BackwardConnection"; - + + private async ValueTask OnChildCompletedCounterBasedLogicAsync(ActivityCompletedContext context) { var flowchartContext = context.TargetContext; @@ -19,9 +21,108 @@ public partial class Flowchart var completedActivity = completedActivityContext.Activity; var result = context.Result; + // Determine the outcomes from the completed activity + var outcomes = result is Outcomes o ? o : Outcomes.Default; + + await ProcessChildCompletedAsync(flowchartContext, completedActivity, completedActivityContext, outcomes); + } + + private IActivity? GetStartActivity(ActivityExecutionContext context) + { + // If there's a trigger that triggered this workflow, use that. + var triggerActivityId = context.WorkflowExecutionContext.TriggerActivityId; + var triggerActivity = triggerActivityId != null ? Activities.FirstOrDefault(x => x.Id == triggerActivityId) : null; + + if (triggerActivity != null) + return triggerActivity; + + // If an explicit Start activity was provided, use that. + if (Start != null) + return Start; + + // If there is a Start activity on the flowchart, use that. + var startActivity = Activities.FirstOrDefault(x => x is Start); + + if (startActivity != null) + return startActivity; + + // If there's an activity marked as "Can Start Workflow", use that. + var canStartWorkflowActivity = Activities.FirstOrDefault(x => x.GetCanStartWorkflow()); + + if (canStartWorkflowActivity != null) + return canStartWorkflowActivity; + + // If there is a single activity that has no inbound connections, use that. + var root = GetRootActivity(); + + if (root != null) + return root; + + // If no start activity found, return the first activity. + return Activities.FirstOrDefault(); + } + + /// + /// Checks if there is any pending work for the flowchart. + /// + private bool HasPendingWork(ActivityExecutionContext context) + { + var workflowExecutionContext = context.WorkflowExecutionContext; + + // Use HashSet for O(1) lookups + var activityIds = new HashSet(Activities.Select(x => x.Id)); + + // Short circuit evaluation - check running instances first before more expensive scheduler check + if (context.Children.Any(x => activityIds.Contains(x.Activity.Id) && x.Status == ActivityStatus.Running)) + return true; + + // Scheduler check - optimize to avoid repeated LINQ evaluations + var scheduledItems = workflowExecutionContext.Scheduler.List().ToList(); + + return scheduledItems.Any(workItem => + { + var ownerInstanceId = workItem.Owner?.Id; + + if (ownerInstanceId == null) + return false; + + if (ownerInstanceId == context.Id) + return true; + + var ownerContext = workflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId); + return ownerContext.GetAncestors().Any(x => x == context); + }); + } + + private IActivity? GetRootActivity() + { + // Get the first activity that has no inbound connections. + var query = + from activity in Activities + let inboundConnections = Connections.Any(x => x.Target.Activity == activity) + where !inboundConnections + select activity; + + var rootActivity = query.FirstOrDefault(); + return rootActivity; + } + + private FlowGraph GetFlowGraph(ActivityExecutionContext context) + { + // Store in TransientProperties so FlowChart is not persisted in WorkflowState + return context.TransientProperties.GetOrAdd(GraphTransientProperty, () => new FlowGraph(Connections, GetStartActivity(context))); + } + + private FlowScope GetFlowScope(ActivityExecutionContext context) + { + return context.GetProperty(ScopeProperty, () => new FlowScope()); + } + + private async ValueTask ProcessChildCompletedAsync(ActivityExecutionContext flowchartContext, IActivity completedActivity, ActivityExecutionContext completedActivityContext, Outcomes outcomes) + { if (flowchartContext.Activity != this) { - throw new Exception("Target context activity must be this flowchart"); + throw new("Target context activity must be this flowchart"); } // If the completed activity's status is anything but "Completed", do not schedule its outbound activities. @@ -37,14 +138,11 @@ public partial class Flowchart return; } - // Determine the outcomes from the completed activity - var outcomes = result is Outcomes o ? o : Outcomes.Default; - // Schedule the outbound activities - var flowGraph = flowchartContext.GetFlowGraph(); + var flowGraph = GetFlowGraph(flowchartContext); var flowScope = GetFlowScope(flowchartContext); - var completedActivityExecutedByBackwardConnection = completedActivityContext.ActivityInput.GetValueOrDefault(BackwardConnectionActivityInput); - bool hasScheduledActivity = await ScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, completedActivity, outcomes, completedActivityExecutedByBackwardConnection); + var completedActivityExcecutedByBackwardConnection = completedActivityContext.ActivityInput.GetValueOrDefault(BackwardConnectionActivityInput); + bool hasScheduledActivity = await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, completedActivity, outcomes, OnChildCompletedAsync, completedActivityExcecutedByBackwardConnection); // If there are not any outbound connections, complete the flowchart activity if there is no other pending work if (!hasScheduledActivity) @@ -53,31 +151,30 @@ public partial class Flowchart } } - private FlowScope GetFlowScope(ActivityExecutionContext context) - { - return context.GetProperty(ScopeProperty, () => new FlowScope()); - } - /// /// Schedules outbound activities based on the flowchart's structure and execution state. /// This method determines whether an activity should be scheduled based on visited connections, - /// forward traversal rules, and backward connections. + /// forward traversal rules, and backward connections. If outcomes is Outcomes.Empty, it indicates + /// that the activity should be skipped - all outbound connections will be visited and treated as + /// not followed. /// /// The graph representation of the flowchart. /// Tracks activity and connection visits. /// The execution context of the flowchart. /// The current activity being processed. /// The outcomes that determine which connections were followed. - /// Indicates if the completed activity was executed due to a backward connection. + /// The callback to invoke upon activity completion. + /// Indicates if the completed activity + /// was executed due to a backward connection. /// True if at least one activity was scheduled; otherwise, false. - private async ValueTask ScheduleOutboundActivitiesAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity activity, Outcomes outcomes, bool completedActivityExecutedByBackwardConnection = false) + private static async ValueTask MaybeScheduleOutboundActivitiesAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity activity, Outcomes outcomes, ActivityCompletionCallback completionCallback, bool completedActivityExecutedByBackwardConnection = false) { - var hasScheduledActivity = false; + bool hasScheduledActivity = false; // Check if the activity is dangling (i.e., it is not reachable from the flowchart graph) if (flowGraph.IsDanglingActivity(activity)) { - throw new Exception($"Activity {activity.Id} is not reachable from the flowchart graph. Unable to schedule it's outbound activities."); + throw new($"Activity {activity.Id} is not reachable from the flowchart graph. Unable to schedule it's outbound activities."); } // Register the activity as visited unless it was executed due to a backward connection @@ -93,19 +190,12 @@ public partial class Flowchart flowScope.RegisterConnectionVisit(outboundConnection, connectionFollowed); var outboundActivity = outboundConnection.Target.Activity; - // Determine scheduling strategy based on connection type + // Determine the scheduling strategy based on connection-type. if (flowGraph.IsBackwardConnection(outboundConnection, out var backwardConnectionIsValid)) - { - hasScheduledActivity |= await ScheduleBackwardConnectionActivityAsync(flowGraph, flowchartContext, outboundConnection, outboundActivity, connectionFollowed, backwardConnectionIsValid); - } - else if (outboundActivity is not IJoinNode) - { - hasScheduledActivity |= await ScheduleNonJoinActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity); - } + // Backward connections are scheduled differently + hasScheduledActivity |= await MaybeScheduleBackwardConnectionActivityAsync(flowGraph, flowchartContext, outboundConnection, outboundActivity, connectionFollowed, backwardConnectionIsValid, completionCallback); else - { - hasScheduledActivity |= await ScheduleJoinActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity); - } + hasScheduledActivity |= await MaybeScheduleOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity, completionCallback); } return hasScheduledActivity; @@ -114,7 +204,7 @@ public partial class Flowchart /// /// Schedules an outbound activity that originates from a backward connection. /// - private async ValueTask ScheduleBackwardConnectionActivityAsync(FlowGraph flowGraph, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, bool connectionFollowed, bool backwardConnectionIsValid) + private static async ValueTask MaybeScheduleBackwardConnectionActivityAsync(FlowGraph flowGraph, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, bool connectionFollowed, bool backwardConnectionIsValid, ActivityCompletionCallback completionCallback) { if (!connectionFollowed) { @@ -123,18 +213,13 @@ public partial class Flowchart if (!backwardConnectionIsValid) { - throw new Exception($"Invalid backward connection: Every path from the source ('{outboundConnection.Source.Activity.Id}') must go through the target ('{outboundConnection.Target.Activity.Id}') when tracing back to the start."); + throw new($"Invalid backward connection: Every path from the source ('{outboundConnection.Source.Activity.Id}') must go through the target ('{outboundConnection.Target.Activity.Id}') when tracing back to the start."); } var scheduleWorkOptions = new ScheduleWorkOptions { - CompletionCallback = OnChildCompletedCounterBasedLogicAsync, - Input = new Dictionary() - { - { - BackwardConnectionActivityInput, true - } - } + CompletionCallback = completionCallback, + Input = new Dictionary() { { BackwardConnectionActivityInput, true } } }; await flowchartContext.ScheduleActivityAsync(outboundActivity, scheduleWorkOptions); @@ -142,91 +227,136 @@ public partial class Flowchart } /// - /// Schedules a non-join activity if all its forward inbound connections have been visited. + /// Determines the merge mode for a given outbound activity. If the outbound activity is a FlowJoin, it retrieves its configured + /// mode. Otherwise, it defaults to FlowJoinMode.WaitAllActive for implicit joins. /// - private async ValueTask ScheduleNonJoinActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity) + private static async ValueTask GetMergeModeAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity) { - if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + if (outboundActivity is FlowJoin) { - return false; - } - - if (flowScope.HasFollowedInboundConnection(flowGraph, outboundActivity)) - { - await flowchartContext.ScheduleActivityAsync(outboundActivity, OnChildCompletedCounterBasedLogicAsync); - return true; + var outboundActivityExecutionContext = await flowchartContext.WorkflowExecutionContext.CreateActivityExecutionContextAsync(outboundActivity); + return await outboundActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode); } else { - // Propagate skipped connections by scheduling with Outcomes.Empty - return await ScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, outboundActivity, Outcomes.Empty); + // Implicit join case - treat as WaitAllActive + return FlowJoinMode.WaitAllActive; } } /// /// Schedules a join activity based on inbound connection statuses. /// - private async ValueTask ScheduleJoinActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity) + private static async ValueTask MaybeScheduleOutboundActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, ActivityCompletionCallback completionCallback) { - // Ignore the connection if the join activity has already completed (JoinAny scenario) - if (flowScope.ShouldIgnoreConnection(outboundConnection, outboundActivity)) - { - return false; - } + FlowJoinMode mode = await GetMergeModeAsync(flowchartContext, outboundActivity); - // Schedule the join activity only if at least one inbound connection was followed - if (!flowScope.HasFollowedInboundConnection(flowGraph, outboundActivity)) + return mode switch { - if (flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) - { - // Propagate skipped connections by scheduling with Outcomes.Empty - return await ScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, outboundActivity, Outcomes.Empty); - } - - return false; - } - - // Check for an existing execution context for the join activity - var joinContext = flowchartContext.WorkflowExecutionContext.ActivityExecutionContexts.LastOrDefault(x => - x.ParentActivityExecutionContext == flowchartContext && - x.Activity == outboundActivity && - x.Status is ActivityStatus.Pending or ActivityStatus.Running); - - // If the join activity was already scheduled, do not schedule it again - if (joinContext == null) - { - var activityScheduled = flowchartContext.WorkflowExecutionContext.Scheduler.List().Any(workItem => workItem.Owner == flowchartContext && workItem.Activity == outboundActivity); - if (activityScheduled) - { - return true; - } - } - - if (joinContext is not { Status: ActivityStatus.Running }) - { - var scheduleWorkOptions = new ScheduleWorkOptions - { - CompletionCallback = OnChildCompletedCounterBasedLogicAsync, - ExistingActivityExecutionContext = joinContext - }; - await flowchartContext.ScheduleActivityAsync(outboundActivity, scheduleWorkOptions); - return true; - } - else - { - return false; - } + FlowJoinMode.WaitAll => await MaybeScheduleWaitAllActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback), + FlowJoinMode.WaitAllActive => await MaybeScheduleWaitAllActiveActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback), + FlowJoinMode.WaitAny => await MaybeScheduleWaitAnyActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity, completionCallback), + _ => throw new($"Unsupported FlowJoinMode: {mode}"), + }; } - public static bool CanWaitAllProceed(ActivityExecutionContext context) + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAll behavior. + /// If all inbound connections were visited, it checks if they were all followed to decide whether to schedule or skip the activity. + /// + private static async ValueTask MaybeScheduleWaitAllActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) { - var flowchartContext = context.ParentActivityExecutionContext!; - var flowchart = (Flowchart)flowchartContext.Activity; - var flowGraph = flowchartContext.GetFlowGraph(); - var flowScope = flowchart.GetFlowScope(flowchartContext); - var activity = context.Activity; + if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // Not all inbound connections have been visited yet; do not schedule anything yet. + return false; - return flowScope.AllInboundConnectionsVisited(flowGraph, activity); + if (flowScope.AllInboundConnectionsFollowed(flowGraph, outboundActivity)) + // All inbound connections were followed; schedule the outbound activity. + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + else + // No inbound connections were followed; skip the outbound activity. + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + } + + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAllActive behavior. + /// If all inbound connections have been visited, it checks if any were followed to decide whether to schedule or skip the activity. + /// + private static async ValueTask MaybeScheduleWaitAllActiveActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // Not all inbound connections have been visited yet; do not schedule anything yet. + return false; + + if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) + // At least one inbound connection was followed; schedule the outbound activity. + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + else + // No inbound connections were followed; skip the outbound activity. + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + } + + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAny behavior. + /// If any inbound connection has been followed, it schedules the activity and cancels remaining inbound activities. + /// If a subsequent inbound connection is followed after the activity has been scheduled, it ignores it. + /// + private static async ValueTask MaybeScheduleWaitAnyActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + if (flowScope.ShouldIgnoreConnection(outboundConnection, outboundActivity)) + // Ignore the connection if the outbound activity has already completed (JoinAny scenario) + return false; + + if (flowchartContext.WorkflowExecutionContext.Scheduler.List().Any(workItem => workItem.Owner == flowchartContext && workItem.Activity == outboundActivity)) + // Ignore the connection if the outbound activity is already scheduled + return false; + + if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) + { + // An inbound connection has been followed; cancel remaining inbound activities + await CancelRemainingInboundActivitiesAsync(flowchartContext, outboundActivity); + + // This is the first inbound connection followed; schedule the outbound activity + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + } + + if (flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // All inbound connections have been visited without any being followed; skip the outbound activity + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + + // No inbound connections have been followed yet; do not schedule anything yet. + return false; + } + + /// + /// Schedules the outbound activity. + /// + private static async ValueTask ScheduleOutboundActivityAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + await flowchartContext.ScheduleActivityAsync(outboundActivity, completionCallback); + return true; + } + + /// + /// Skips the outbound activity by propagating skipped connections. + /// + private static async ValueTask SkipOutboundActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + return await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, outboundActivity, Outcomes.Empty, completionCallback); + } + + private static async ValueTask CancelRemainingInboundActivitiesAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity) + { + var flowchart = (Flowchart)flowchartContext.Activity; + var flowGraph = flowchart.GetFlowGraph(flowchartContext); + var ancestorActivities = flowGraph.GetAncestorActivities(outboundActivity); + var inboundActivityExecutionContexts = flowchartContext.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == flowchartContext).ToList(); + + // Cancel each ancestor activity. + foreach (var activityExecutionContext in inboundActivityExecutionContexts) + { + await activityExecutionContext.CancelActivityAsync(); + } } private async ValueTask OnScheduleOutcomesAsync(ScheduleActivityOutcomes signal, SignalContext context) @@ -234,15 +364,7 @@ public partial class Flowchart var flowchartContext = context.ReceiverActivityExecutionContext; var schedulingActivityContext = context.SenderActivityExecutionContext; var schedulingActivity = schedulingActivityContext.Activity; - var outcomes = signal.Outcomes; - var outboundConnections = Connections.Where(connection => connection.Source.Activity == schedulingActivity && outcomes.Contains(connection.Source.Port!)).ToList(); - var outboundActivities = outboundConnections.Select(x => x.Target.Activity).ToList(); - - if (outboundActivities.Any()) - { - foreach (var activity in outboundActivities) - await flowchartContext.ScheduleActivityAsync(activity, OnChildCompletedCounterBasedLogicAsync); - } + var outcomes = new Outcomes(signal.Outcomes); } private async ValueTask OnCounterFlowActivityCanceledAsync(CancelSignal signal, SignalContext context) @@ -254,6 +376,6 @@ public partial class Flowchart var flowScope = flowchart.GetFlowScope(flowchartContext); // Propagate canceled connections visited count by scheduling with Outcomes.Empty - await flowchart.ScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, context.SenderActivityExecutionContext.Activity, Outcomes.Empty); + await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, context.SenderActivityExecutionContext.Activity, Outcomes.Empty, OnChildCompletedAsync); } } \ No newline at end of file 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 a35408447..8fa1c5d06 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using System.Runtime.CompilerServices; -using Elsa.Extensions; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Attributes; using Elsa.Workflows.Signals; @@ -40,7 +39,7 @@ public partial class Flowchart : Container /// protected override async ValueTask ScheduleChildrenAsync(ActivityExecutionContext context) { - var startActivity = this.GetStartActivity(context.WorkflowExecutionContext.TriggerActivityId); + var startActivity = GetStartActivity(context); if (startActivity == null) { @@ -52,379 +51,6 @@ public partial class Flowchart : Container await context.ScheduleActivityAsync(startActivity, OnChildCompletedAsync); } -// BEGIN 3.5 - private IActivity? GetStartActivity(ActivityExecutionContext context) - { - // If there's a trigger that triggered this workflow, use that. - var triggerActivityId = context.WorkflowExecutionContext.TriggerActivityId; - var triggerActivity = triggerActivityId != null ? Activities.FirstOrDefault(x => x.Id == triggerActivityId) : null; - - if (triggerActivity != null) - return triggerActivity; - - // If an explicit Start activity was provided, use that. - if (Start != null) - return Start; - - // If there is a Start activity on the flowchart, use that. - var startActivity = Activities.FirstOrDefault(x => x is Start); - - if (startActivity != null) - return startActivity; - - // If there's an activity marked as "Can Start Workflow", use that. - var canStartWorkflowActivity = Activities.FirstOrDefault(x => x.GetCanStartWorkflow()); - - if (canStartWorkflowActivity != null) - return canStartWorkflowActivity; - - // If there is a single activity that has no inbound connections, use that. - var root = GetRootActivity(); - - if (root != null) - return root; - - // If no start activity found, return the first activity. - return Activities.FirstOrDefault(); - } - - /// - /// Checks if there is any pending work for the flowchart. - /// - private bool HasPendingWork(ActivityExecutionContext context) - { - var workflowExecutionContext = context.WorkflowExecutionContext; - - // Use HashSet for O(1) lookups - var activityIds = new HashSet(Activities.Select(x => x.Id)); - - // Short circuit evaluation - check running instances first before more expensive scheduler check - if (context.Children.Any(x => activityIds.Contains(x.Activity.Id) && x.Status == ActivityStatus.Running)) - return true; - - // Scheduler check - optimize to avoid repeated LINQ evaluations - var scheduledItems = workflowExecutionContext.Scheduler.List().ToList(); - - return scheduledItems.Any(workItem => - { - var ownerInstanceId = workItem.Owner?.Id; - - if (ownerInstanceId == null) - return false; - - if (ownerInstanceId == context.Id) - return true; - - var ownerContext = workflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId); - return ownerContext.GetAncestors().Any(x => x == context); - }); - } - - private IActivity? GetRootActivity() - { - // Get the first activity that has no inbound connections. - var query = - from activity in Activities - let inboundConnections = Connections.Any(x => x.Target.Activity == activity) - where !inboundConnections - select activity; - - var rootActivity = query.FirstOrDefault(); - return rootActivity; - } - - private FlowGraph GetFlowGraph(ActivityExecutionContext context) - { - // Store in TransientProperties so FlowChart is not persisted in WorkflowState - return context.TransientProperties.GetOrAdd(GraphTransientProperty, () => new FlowGraph(Connections, GetStartActivity(context))); - } - - private FlowScope GetFlowScope(ActivityExecutionContext context) - { - return context.GetProperty(ScopeProperty, () => new FlowScope()); - } - - private async ValueTask OnChildCompletedAsync(ActivityCompletedContext context) - { - var flowchartContext = context.TargetContext; - var completedActivityContext = context.ChildContext; - var completedActivity = completedActivityContext.Activity; - var result = context.Result; - - // Determine the outcomes from the completed activity - var outcomes = result is Outcomes o ? o : Outcomes.Default; - - await ProcessChildCompletedAsync(flowchartContext, completedActivity, completedActivityContext, outcomes); - } - - private async ValueTask ProcessChildCompletedAsync(ActivityExecutionContext flowchartContext, IActivity completedActivity, ActivityExecutionContext completedActivityContext, Outcomes outcomes) - { - if (flowchartContext.Activity != this) - { - throw new("Target context activity must be this flowchart"); - } - - // If the completed activity's status is anything but "Completed", do not schedule its outbound activities. - if (completedActivityContext.Status != ActivityStatus.Completed) - { - return; - } - - // If the complete activity is a terminal node, complete the flowchart immediately. - if (completedActivity is ITerminalNode) - { - await flowchartContext.CompleteActivityAsync(); - return; - } - - // Schedule the outbound activities - var flowGraph = GetFlowGraph(flowchartContext); - var flowScope = GetFlowScope(flowchartContext); - var completedActivityExcecutedByBackwardConnection = completedActivityContext.ActivityInput.GetValueOrDefault(BackwardConnectionActivityInput); - bool hasScheduledActivity = await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, completedActivity, outcomes, OnChildCompletedAsync, completedActivityExcecutedByBackwardConnection); - - // If there are not any outbound connections, complete the flowchart activity if there is no other pending work - if (!hasScheduledActivity) - { - await CompleteIfNoPendingWorkAsync(flowchartContext); - } - } - - /// - /// Schedules outbound activities based on the flowchart's structure and execution state. - /// This method determines whether an activity should be scheduled based on visited connections, - /// forward traversal rules, and backward connections. If outcomes is Outcomes.Empty, it indicates - /// that the activity should be skipped - all outbound connections will be visited and treated as - /// not followed. - /// - /// The graph representation of the flowchart. - /// Tracks activity and connection visits. - /// The execution context of the flowchart. - /// The current activity being processed. - /// The outcomes that determine which connections were followed. - /// The callback to invoke upon activity completion. - /// Indicates if the completed activity - /// was executed due to a backward connection. - /// True if at least one activity was scheduled; otherwise, false. - private static async ValueTask MaybeScheduleOutboundActivitiesAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity activity, Outcomes outcomes, ActivityCompletionCallback completionCallback, bool completedActivityExecutedByBackwardConnection = false) - { - bool hasScheduledActivity = false; - - // Check if the activity is dangling (i.e., it is not reachable from the flowchart graph) - if (flowGraph.IsDanglingActivity(activity)) - { - throw new($"Activity {activity.Id} is not reachable from the flowchart graph. Unable to schedule it's outbound activities."); - } - - // Register the activity as visited unless it was executed due to a backward connection - if (!completedActivityExecutedByBackwardConnection) - { - flowScope.RegisterActivityVisit(activity); - } - - // Process each outbound connection from the current activity - foreach (var outboundConnection in flowGraph.GetOutboundConnections(activity)) - { - var connectionFollowed = outcomes.Names.Contains(outboundConnection.Source.Port); - flowScope.RegisterConnectionVisit(outboundConnection, connectionFollowed); - var outboundActivity = outboundConnection.Target.Activity; - - // Determine the scheduling strategy based on connection-type. - if (flowGraph.IsBackwardConnection(outboundConnection, out var backwardConnectionIsValid)) - // Backward connections are scheduled differently - hasScheduledActivity |= await MaybeScheduleBackwardConnectionActivityAsync(flowGraph, flowchartContext, outboundConnection, outboundActivity, connectionFollowed, backwardConnectionIsValid, completionCallback); - else - hasScheduledActivity |= await MaybeScheduleOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity, completionCallback); - } - - return hasScheduledActivity; - } - - /// - /// Schedules an outbound activity that originates from a backward connection. - /// - private static async ValueTask MaybeScheduleBackwardConnectionActivityAsync(FlowGraph flowGraph, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, bool connectionFollowed, bool backwardConnectionIsValid, ActivityCompletionCallback completionCallback) - { - if (!connectionFollowed) - { - return false; - } - - if (!backwardConnectionIsValid) - { - throw new($"Invalid backward connection: Every path from the source ('{outboundConnection.Source.Activity.Id}') must go through the target ('{outboundConnection.Target.Activity.Id}') when tracing back to the start."); - } - - var scheduleWorkOptions = new ScheduleWorkOptions - { - CompletionCallback = completionCallback, - Input = new Dictionary() { { BackwardConnectionActivityInput, true } } - }; - - await flowchartContext.ScheduleActivityAsync(outboundActivity, scheduleWorkOptions); - return true; - } - - /// - /// Determines the merge mode for a given outbound activity. If the outbound activity is a FlowJoin, it retrieves its configured - /// mode. Otherwise, it defaults to FlowJoinMode.WaitAllActive for implicit joins. - /// - private static async ValueTask GetMergeModeAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity) - { - if (outboundActivity is FlowJoin) - { - var outboundActivityExecutionContext = await flowchartContext.WorkflowExecutionContext.CreateActivityExecutionContextAsync(outboundActivity); - return await outboundActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode); - } - else - { - // Implicit join case - treat as WaitAllActive - return FlowJoinMode.WaitAllActive; - } - } - - /// - /// Schedules a join activity based on inbound connection statuses. - /// - private static async ValueTask MaybeScheduleOutboundActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, ActivityCompletionCallback completionCallback) - { - FlowJoinMode mode = await GetMergeModeAsync(flowchartContext, outboundActivity); - - return mode switch - { - FlowJoinMode.WaitAll => await MaybeScheduleWaitAllActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback), - FlowJoinMode.WaitAllActive => await MaybeScheduleWaitAllActiveActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback), - FlowJoinMode.WaitAny => await MaybeScheduleWaitAnyActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity, completionCallback), - _ => throw new($"Unsupported FlowJoinMode: {mode}"), - }; - } - - /// - /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAll behavior. - /// If all inbound connections were visited, it checks if they were all followed to decide whether to schedule or skip the activity. - /// - private static async ValueTask MaybeScheduleWaitAllActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) - { - if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) - // Not all inbound connections have been visited yet; do not schedule anything yet. - return false; - - if (flowScope.AllInboundConnectionsFollowed(flowGraph, outboundActivity)) - // All inbound connections were followed; schedule the outbound activity. - return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); - else - // No inbound connections were followed; skip the outbound activity. - return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); - } - - /// - /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAllActive behavior. - /// If all inbound connections have been visited, it checks if any were followed to decide whether to schedule or skip the activity. - /// - private static async ValueTask MaybeScheduleWaitAllActiveActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) - { - if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) - // Not all inbound connections have been visited yet; do not schedule anything yet. - return false; - - if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) - // At least one inbound connection was followed; schedule the outbound activity. - return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); - else - // No inbound connections were followed; skip the outbound activity. - return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); - } - - /// - /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAny behavior. - /// If any inbound connection has been followed, it schedules the activity and cancels remaining inbound activities. - /// If a subsequent inbound connection is followed after the activity has been scheduled, it ignores it. - /// - private static async ValueTask MaybeScheduleWaitAnyActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, ActivityCompletionCallback completionCallback) - { - if (flowScope.ShouldIgnoreConnection(outboundConnection, outboundActivity)) - // Ignore the connection if the outbound activity has already completed (JoinAny scenario) - return false; - - if (flowchartContext.WorkflowExecutionContext.Scheduler.List().Any(workItem => workItem.Owner == flowchartContext && workItem.Activity == outboundActivity)) - // Ignore the connection if the outbound activity is already scheduled - return false; - - if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) - { - // An inbound connection has been followed; cancel remaining inbound activities - await CancelRemainingInboundActivitiesAsync(flowchartContext, outboundActivity); - - // This is the first inbound connection followed; schedule the outbound activity - return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); - } - - if (flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) - // All inbound connections have been visited without any being followed; skip the outbound activity - return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); - - // No inbound connections have been followed yet; do not schedule anything yet. - return false; - } - - /// - /// Schedules the outbound activity. - /// - private static async ValueTask ScheduleOutboundActivityAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) - { - await flowchartContext.ScheduleActivityAsync(outboundActivity, completionCallback); - return true; - } - - /// - /// Skips the outbound activity by propagating skipped connections. - /// - private static async ValueTask SkipOutboundActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) - { - return await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, outboundActivity, Outcomes.Empty, completionCallback); - } - - private static async ValueTask CancelRemainingInboundActivitiesAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity) - { - var flowchart = (Flowchart)flowchartContext.Activity; - var flowGraph = flowchart.GetFlowGraph(flowchartContext); - var ancestorActivities = flowGraph.GetAncestorActivities(outboundActivity); - var inboundActivityExecutionContexts = flowchartContext.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == flowchartContext).ToList(); - - // Cancel each ancestor activity. - foreach (var activityExecutionContext in inboundActivityExecutionContexts) - { - await activityExecutionContext.CancelActivityAsync(); - } - } - - private async Task CompleteIfNoPendingWorkAsync(ActivityExecutionContext context) - { - var hasPendingWork = HasPendingWork(context); - - if (!hasPendingWork) - { - var hasFaultedActivities = context.Children.Any(x => x.Status == ActivityStatus.Faulted); - - if (!hasFaultedActivities) - { - await context.CompleteActivityAsync(); - } - } - } - - private async ValueTask OnScheduleOutcomesAsync(ScheduleActivityOutcomes signal, SignalContext context) - { - var flowchartContext = context.ReceiverActivityExecutionContext; - var schedulingActivityContext = context.SenderActivityExecutionContext; - var schedulingActivity = schedulingActivityContext.Activity; - var outcomes = new Outcomes(signal.Outcomes); - - await ProcessChildCompletedAsync(flowchartContext, schedulingActivity, schedulingActivityContext, outcomes); - } - -// END 3.5 - private async ValueTask OnScheduleChildActivityAsync(ScheduleChildActivity signal, SignalContext context) { var flowchartContext = context.ReceiverActivityExecutionContext; @@ -466,9 +92,16 @@ public partial class Flowchart : Container private async Task CompleteIfNoPendingWorkAsync(ActivityExecutionContext context) { - var hasPendingWork = context.HasPendingWork(); + var hasPendingWork = HasPendingWork(context); if (!hasPendingWork) - await context.CompleteActivityAsync(); + { + var hasFaultedActivities = context.Children.Any(x => x.Status == ActivityStatus.Faulted); + + if (!hasFaultedActivities) + { + await context.CompleteActivityAsync(); + } + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs index 72f50335a..b0c896410 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs @@ -72,40 +72,30 @@ public static class ExpressionExecutionContextExtensions /// public bool TryGetWorkflowExecutionContext(out WorkflowExecutionContext workflowExecutionContext) => context.TransientProperties.TryGetValue(WorkflowExecutionContextKey, out workflowExecutionContext!); - /// - /// Returns the of the specified - /// - public static WorkflowExecutionContext GetWorkflowExecutionContext(this ExpressionExecutionContext context) - { - return context.TransientProperties.TryGetValue(WorkflowExecutionContextKey, out var value) - ? (WorkflowExecutionContext)value - : throw new InvalidOperationException("WorkflowExecutionContext not found. This value exists only on activity execution contexts."); - } + /// + /// Returns the of the specified + /// + public WorkflowExecutionContext GetWorkflowExecutionContext() + { + return context.TransientProperties.TryGetValue(WorkflowExecutionContextKey, out var value) + ? (WorkflowExecutionContext)value + : throw new InvalidOperationException("WorkflowExecutionContext not found. This value exists only on activity execution contexts."); + } - /// - /// Returns the of the specified - /// - public static ActivityExecutionContext GetActivityExecutionContext(this ExpressionExecutionContext context) - { - return context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out var value) - ? (ActivityExecutionContext)value - : throw new InvalidOperationException("ActivityExecutionContext not found. This value exists only on activity execution contexts."); - } + /// + /// Returns the of the specified + /// + public ActivityExecutionContext GetActivityExecutionContext() + { + return context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out var value) + ? (ActivityExecutionContext)value + : throw new InvalidOperationException("ActivityExecutionContext not found. This value exists only on activity execution contexts."); + } - /// - /// Returns the of the specified - /// - public static bool TryGetActivityExecutionContext(this ExpressionExecutionContext context, out ActivityExecutionContext activityExecutionContext) => context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out activityExecutionContext!); - - /// - /// Returns the of the specified - /// - public static IActivity GetActivity(this ExpressionExecutionContext context) - { - return context.TransientProperties.TryGetValue(ActivityKey, out var value) - ? (IActivity)value - : throw new InvalidOperationException("Activity not found. This value exists only on activity execution contexts."); - } + /// + /// Returns the of the specified + /// + public bool TryGetActivityExecutionContext(out ActivityExecutionContext activityExecutionContext) => context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out activityExecutionContext!); /// /// Returns the of the specified @@ -202,7 +192,7 @@ public static class ExpressionExecutionContextExtensions var variable = context.GetVariable(name); if (variable == null) - return CreateVariable(context, name, value, configure: configure); + return context.CreateVariable(name, value, configure: configure); // Get the context where the variable is defined. var contextWithVariable = context.FindContextContainingBlock(variable.Id) ?? context; @@ -307,7 +297,7 @@ public static class ExpressionExecutionContextExtensions /// Gets all variables names in scope. /// public IEnumerable GetVariableNamesInScope() => - EnumerateVariablesInScope(context) + context.EnumerateVariablesInScope() .Select(x => x.Name) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(); @@ -316,7 +306,7 @@ public static class ExpressionExecutionContextExtensions /// Gets all variables in scope. /// public IEnumerable GetVariablesInScope() => - EnumerateVariablesInScope(context) + context.EnumerateVariablesInScope() .Where(x => !string.IsNullOrWhiteSpace(x.Name)) .DistinctBy(x => x.Name); @@ -325,7 +315,7 @@ public static class ExpressionExecutionContextExtensions /// public void SetVariableInScope(string variableName, object? value) { - var q = from v in EnumerateVariablesInScope(context) + var q = from v in context.EnumerateVariablesInScope() where v.Name == variableName where v.TryGet(context, out _) select v; @@ -336,7 +326,7 @@ public static class ExpressionExecutionContextExtensions variable.Set(context, value); if (variable == null) - CreateVariable(context, variableName, value); + context.CreateVariable(variableName, value); } /// @@ -402,7 +392,6 @@ public static class ExpressionExecutionContextExtensions return serializerOptions; } - /// extension(ExpressionExecutionContext context) { /// diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs index a46e1aa4b..556e89251 100644 --- a/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs +++ b/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs @@ -1,5 +1,5 @@ +using Elsa.Expressions.JavaScript.Contracts; using Elsa.Expressions.Models; -using Elsa.JavaScript.Contracts; using Elsa.Testing.Shared; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs index 5ddb29eb3..1cb751446 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs @@ -369,6 +369,7 @@ public class FlowchartNextActivityTests // F public async Task JoinBehavesCorrectly(bool decisionResult, FlowJoinMode joinMode, string[] expectedLines) { + Flowchart.UseTokenFlow = false; var workflow = new TestWorkflow(workflowBuilder => { var start = new Start() { Id = "Start" }; @@ -423,5 +424,6 @@ public class FlowchartNextActivityTests var lines = _capturingTextWriter.Lines.ToList(); Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); Assert.Equal(expectedLines, lines); + Flowchart.UseTokenFlow = true; } } \ No newline at end of file From cd0cd10fb1311c9c13ed3b688240b54f0eb31e12 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 21:42:07 +0100 Subject: [PATCH 21/27] Update Dockerfiles to use .NET SDK and ASP.NET runtime 10.0-bookworm-slim --- docker/ElsaServer-Datadog.Dockerfile | 6 +++--- docker/ElsaServer.Dockerfile | 6 +++--- docker/ElsaServerAndStudio.Dockerfile | 6 +++--- docker/ElsaStudio.Dockerfile | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docker/ElsaServer-Datadog.Dockerfile b/docker/ElsaServer-Datadog.Dockerfile index b7f1a3f4b..4dfabf005 100644 --- a/docker/ElsaServer-Datadog.Dockerfile +++ b/docker/ElsaServer-Datadog.Dockerfile @@ -1,7 +1,7 @@ # Version: 1 # Description: Dockerfile for building and running Elsa Server with Datadog and OpenTelemetry auto-instrumentation -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build WORKDIR /source # Copy sources. @@ -15,10 +15,10 @@ RUN dotnet restore "./src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj" # Build and publish (UseAppHost=false creates platform independent binaries). WORKDIR /source/src/bundles/Elsa.Server.Web RUN dotnet build "Elsa.Server.Web.csproj" -c Release -o /app/build -RUN dotnet publish "Elsa.Server.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net9.0 +RUN dotnet publish "Elsa.Server.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # Move binaries into smaller base image. -FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ diff --git a/docker/ElsaServer.Dockerfile b/docker/ElsaServer.Dockerfile index 2695ca4de..79d59c824 100644 --- a/docker/ElsaServer.Dockerfile +++ b/docker/ElsaServer.Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build WORKDIR /source # copy sources. @@ -12,10 +12,10 @@ RUN dotnet restore "./src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj" # build and publish (UseAppHost=false creates platform independent binaries). WORKDIR /source/src/apps/Elsa.Server.Web RUN dotnet build "Elsa.Server.Web.csproj" -c Release -o /app/build -RUN dotnet publish "Elsa.Server.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net9.0 +RUN dotnet publish "Elsa.Server.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # move binaries into smaller base image. -FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ diff --git a/docker/ElsaServerAndStudio.Dockerfile b/docker/ElsaServerAndStudio.Dockerfile index aaadba910..521934b1f 100644 --- a/docker/ElsaServerAndStudio.Dockerfile +++ b/docker/ElsaServerAndStudio.Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build WORKDIR /source # copy sources. @@ -13,10 +13,10 @@ RUN dotnet restore "./src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web # build and publish (UseAppHost=false creates platform independent binaries). WORKDIR /source/src/apps/Elsa.ServerAndStudio.Web RUN dotnet build "Elsa.ServerAndStudio.Web.csproj" -c Release -o /app/build -RUN dotnet publish "Elsa.ServerAndStudio.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net9.0 +RUN dotnet publish "Elsa.ServerAndStudio.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # move binaries into smaller base image. -FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ diff --git a/docker/ElsaStudio.Dockerfile b/docker/ElsaStudio.Dockerfile index 6e76acb3d..abe39b0b0 100644 --- a/docker/ElsaStudio.Dockerfile +++ b/docker/ElsaStudio.Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build WORKDIR /source # copy sources. @@ -13,10 +13,10 @@ RUN dotnet restore "./src/apps/Elsa.Studio.Web/Elsa.Studio.Web.csproj" # build and publish (UseAppHost=false creates platform independent binaries). WORKDIR /source/src/apps/Elsa.Studio.Web RUN dotnet build "Elsa.Studio.Web.csproj" -c Release -o /app/build -RUN dotnet publish "Elsa.Studio.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net9.0 +RUN dotnet publish "Elsa.Studio.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # move binaries into smaller base image. -FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ From 068b2079cc9134b8a0fde9d3b5c76ba7bd274ec2 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Dec 2025 21:44:48 +0100 Subject: [PATCH 22/27] Update Dockerfiles to remove "bookworm-slim" tag from .NET SDK and ASP.NET runtime images --- docker/ElsaServer-Datadog.Dockerfile | 4 ++-- docker/ElsaServer.Dockerfile | 4 ++-- docker/ElsaServerAndStudio.Dockerfile | 4 ++-- docker/ElsaStudio.Dockerfile | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docker/ElsaServer-Datadog.Dockerfile b/docker/ElsaServer-Datadog.Dockerfile index 4dfabf005..9305f71e8 100644 --- a/docker/ElsaServer-Datadog.Dockerfile +++ b/docker/ElsaServer-Datadog.Dockerfile @@ -1,7 +1,7 @@ # Version: 1 # Description: Dockerfile for building and running Elsa Server with Datadog and OpenTelemetry auto-instrumentation -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /source # Copy sources. @@ -18,7 +18,7 @@ RUN dotnet build "Elsa.Server.Web.csproj" -c Release -o /app/build RUN dotnet publish "Elsa.Server.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # Move binaries into smaller base image. -FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app COPY --from=build /app/publish ./ diff --git a/docker/ElsaServer.Dockerfile b/docker/ElsaServer.Dockerfile index 79d59c824..c194e4253 100644 --- a/docker/ElsaServer.Dockerfile +++ b/docker/ElsaServer.Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /source # copy sources. @@ -15,7 +15,7 @@ RUN dotnet build "Elsa.Server.Web.csproj" -c Release -o /app/build RUN dotnet publish "Elsa.Server.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # move binaries into smaller base image. -FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app COPY --from=build /app/publish ./ diff --git a/docker/ElsaServerAndStudio.Dockerfile b/docker/ElsaServerAndStudio.Dockerfile index 521934b1f..726904cd0 100644 --- a/docker/ElsaServerAndStudio.Dockerfile +++ b/docker/ElsaServerAndStudio.Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /source # copy sources. @@ -16,7 +16,7 @@ RUN dotnet build "Elsa.ServerAndStudio.Web.csproj" -c Release -o /app/build RUN dotnet publish "Elsa.ServerAndStudio.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # move binaries into smaller base image. -FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app COPY --from=build /app/publish ./ diff --git a/docker/ElsaStudio.Dockerfile b/docker/ElsaStudio.Dockerfile index abe39b0b0..34b69c0c9 100644 --- a/docker/ElsaStudio.Dockerfile +++ b/docker/ElsaStudio.Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /source # copy sources. @@ -16,7 +16,7 @@ RUN dotnet build "Elsa.Studio.Web.csproj" -c Release -o /app/build RUN dotnet publish "Elsa.Studio.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net10.0 # move binaries into smaller base image. -FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app COPY --from=build /app/publish ./ From 7add1030c41a228fa8ffc7440d7167cf2f9ebf04 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 11 Dec 2025 09:17:35 +0100 Subject: [PATCH 23/27] Update documentation to reflect .NET 10.0 support and remove deprecated references to external dependencies --- .github/copilot-instructions.md | 25 ++++--------------------- NuGet.Config | 11 ----------- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 41f39a82b..2cb307856 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,15 +2,15 @@ ## Repository Overview -**Elsa Workflows** is a powerful .NET workflow library that enables workflow execution within any .NET application. This is version 3.0, supporting .NET 9.0 and providing both a visual designer and programmatic workflow definition capabilities. +**Elsa Workflows** is a powerful .NET workflow library that enables workflow execution within any .NET application. This is version 3.0, supporting .NET 8.0, .NET 9.0 and .NET 10.0 and providing both a visual designer (from a different repository, elsa-studio) and programmatic workflow definition capabilities. ### Key Statistics -- **Language**: C# (.NET 9.0) +- **Language**: C# (.NET 10.0) - **Architecture**: Modular library with 104+ projects - **Code Size**: ~3,500 C# files across modules - **License**: MIT - **Build System**: NUKE build automation -- **Target Frameworks**: .NET 9.0 (primary) +- **Target Frameworks**: .NET 10.0 (primary) ## High-Level Architecture @@ -19,9 +19,6 @@ src/ ├── apps/ # Reference applications (5 projects) │ ├── Elsa.Server.Web # Workflow server only -│ ├── Elsa.ServerAndStudio.Web # Combined server + studio -│ ├── Elsa.Studio.Web # Studio web interface -│ ├── ElsaStudioWebAssembly # Studio WebAssembly app │ └── Elsa.Server.LoadBalancer # Load balancer ├── common/ # Shared libraries (8 projects) ├── modules/ # Core functionality modules (70+ projects) @@ -46,7 +43,7 @@ docker/ # Docker configurations - **Elsa.Workflows.Runtime**: Workflow execution runtime - **Elsa.Workflows.Api**: RESTful API for workflow management - **Elsa.Workflows.Management**: Workflow definition management -- **Elsa modules**: Specialized functionality (HTTP, email, scheduling, etc.) +- **Elsa modules**: Specialized functionality (HTTP, persistence, scheduling, etc.) ## Build Instructions @@ -54,20 +51,6 @@ docker/ # Docker configurations - **.NET 10.0 SDK** - **Build time**: Initial restore ~1-2 minutes, full compile ~5-10 minutes -### Critical Build Information - -⚠️ **IMPORTANT**: The repository has external dependencies that may cause build failures: - -1. **External NuGet Feeds**: Some projects depend on packages from: - - `https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json` (Elsa Studio packages) - - `https://f.feedz.io/sfmskywalker/webhooks-core/nuget/index.json` (Webhooks packages) - -2. **Build Failure Workarounds**: - - Studio apps (`Elsa.Studio.Web`, `ElsaStudioWebAssembly`, `Elsa.ServerAndStudio.Web`) depend on prebuilt studio packages that may not be accessible - - Server app (`Elsa.Server.Web`) depends on WebhooksCore package that may not be accessible - - Core workflow functionality can be built independently - - Some test projects may fail due to missing external packages - ### Build Commands **Primary build script**: `./build.sh` (Linux/macOS) or `.\build.cmd` (Windows) diff --git a/NuGet.Config b/NuGet.Config index 31400bd46..54f245bd3 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -4,21 +4,10 @@ - - - - - - - - - - - \ No newline at end of file From 1107b48200ce8ff9ee9ec53c12fdada914d5ffe3 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 11 Dec 2025 09:45:37 +0100 Subject: [PATCH 24/27] Switch component tests from PostgreSQL to SQL Server and update related configurations. --- Directory.Packages.props | 1 + test/component/Directory.Build.props | 17 +++++------ .../Elsa.Workflows.ComponentTests.csproj | 1 + .../Helpers/Fixtures/Infrastructure.cs | 28 +++++++++++-------- .../Helpers/Fixtures/WorkflowServer.cs | 26 +++++++++++++---- 5 files changed, 49 insertions(+), 24 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a0ce735c..ce19a6446 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -165,6 +165,7 @@ + diff --git a/test/component/Directory.Build.props b/test/component/Directory.Build.props index e6ff014e4..fb63b368e 100644 --- a/test/component/Directory.Build.props +++ b/test/component/Directory.Build.props @@ -1,14 +1,15 @@ - + - - 40 - + + 40 + - - - - + + + + + \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj index 7d4751127..dbfd8dcec 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj +++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj @@ -22,6 +22,7 @@ + diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs index 49f1e1382..45e547ac8 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs @@ -1,3 +1,4 @@ +using Testcontainers.MsSql; using Testcontainers.PostgreSql; using Testcontainers.RabbitMq; @@ -5,17 +6,22 @@ namespace Elsa.Workflows.ComponentTests.Fixtures; public class Infrastructure : IAsyncLifetime { - public readonly PostgreSqlContainer DbContainer = new PostgreSqlBuilder() - .WithImage("postgres:16-alpine") - .WithDatabase("elsa") - .WithUsername("postgres") - .WithPassword("postgres") - .WithCommand( - "-c", "max_connections=100", - "-c", "shared_buffers=128MB", - "-c", "work_mem=4MB", - "-c", "effective_cache_size=256MB" - ) + // public readonly PostgreSqlContainer DbContainer = new PostgreSqlBuilder() + // .WithImage("postgres:16-alpine") + // .WithDatabase("elsa") + // .WithUsername("postgres") + // .WithPassword("postgres") + // .WithCommand( + // "-c", "max_connections=100", + // "-c", "shared_buffers=128MB", + // "-c", "work_mem=4MB", + // "-c", "effective_cache_size=256MB" + // ) + // .Build(); + + public readonly MsSqlContainer DbContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithPassword("!Elsa2025@") .Build(); public readonly RabbitMqContainer RabbitMqContainer = new RabbitMqBuilder() diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 3c590c0e0..1a10ecba1 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -55,7 +55,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl protected override void ConfigureWebHost(IWebHostBuilder builder) { - var dbConnectionString = infrastructure.DbContainer.GetConnectionString() + ";Minimum Pool Size=0;Maximum Pool Size=10"; + var dbConnectionString = infrastructure.DbContainer.GetConnectionString(); var rabbitMqConnectionString = infrastructure.RabbitMqContainer.GetConnectionString(); builder.UseUrls(url); @@ -78,15 +78,27 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl var workflowsDirectory = Path.Join(workflowsDirectorySegments); return StorageFactory.Blobs.DirectoryFiles(workflowsDirectory); }); - elsa.UseIdentity(identity => identity.UseEntityFrameworkCore(ef => ef.UsePostgreSql(dbConnectionString))); + elsa.UseIdentity(identity => identity.UseEntityFrameworkCore(ef => + { + //ef.UsePostgreSql(dbConnectionString); + ef.UseSqlServer(dbConnectionString); + })); elsa.UseWorkflowManagement(management => { - management.UseEntityFrameworkCore(ef => ef.UsePostgreSql(dbConnectionString)); + management.UseEntityFrameworkCore(ef => + { + //ef.UsePostgreSql(dbConnectionString); + ef.UseSqlServer(dbConnectionString); + }); management.UseCache(); }); elsa.UseWorkflowRuntime(runtime => { - runtime.UseEntityFrameworkCore(ef => ef.UsePostgreSql(dbConnectionString)); + runtime.UseEntityFrameworkCore(ef => + { + //ef.UsePostgreSql(dbConnectionString); + ef.UseSqlServer(dbConnectionString); + }); runtime.UseCache(); runtime.UseDistributedRuntime(); }); @@ -100,7 +112,11 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl }); elsa.UseAlterations(alterations => { - alterations.UseEntityFrameworkCore(ef => ef.UsePostgreSql(dbConnectionString)); + alterations.UseEntityFrameworkCore(ef => + { + //ef.UsePostgreSql(dbConnectionString); + ef.UseSqlServer(dbConnectionString); + }); }); elsa.UseHttp(http => { From 183934c1affbbe598bceabae1ffc54f47ef17556 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 11 Dec 2025 09:54:16 +0100 Subject: [PATCH 25/27] Revert component tests database configuration from SQL Server to PostgreSQL. --- .../Helpers/Fixtures/Infrastructure.cs | 20 ++++--------------- .../Helpers/Fixtures/WorkflowServer.cs | 16 +++++++-------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs index 45e547ac8..8e2dd1519 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs @@ -6,23 +6,11 @@ namespace Elsa.Workflows.ComponentTests.Fixtures; public class Infrastructure : IAsyncLifetime { - // public readonly PostgreSqlContainer DbContainer = new PostgreSqlBuilder() - // .WithImage("postgres:16-alpine") - // .WithDatabase("elsa") - // .WithUsername("postgres") - // .WithPassword("postgres") - // .WithCommand( - // "-c", "max_connections=100", - // "-c", "shared_buffers=128MB", - // "-c", "work_mem=4MB", - // "-c", "effective_cache_size=256MB" - // ) - // .Build(); + public readonly PostgreSqlContainer DbContainer = new PostgreSqlBuilder().Build(); - public readonly MsSqlContainer DbContainer = new MsSqlBuilder() - .WithImage("mcr.microsoft.com/mssql/server:2022-latest") - .WithPassword("!Elsa2025@") - .Build(); + // public readonly MsSqlContainer DbContainer = new MsSqlBuilder() + // .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + // .Build(); public readonly RabbitMqContainer RabbitMqContainer = new RabbitMqBuilder() .WithImage("rabbitmq:4-management") diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 1a10ecba1..c07fcc0f9 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -80,15 +80,15 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl }); elsa.UseIdentity(identity => identity.UseEntityFrameworkCore(ef => { - //ef.UsePostgreSql(dbConnectionString); - ef.UseSqlServer(dbConnectionString); + ef.UsePostgreSql(dbConnectionString); + //ef.UseSqlServer(dbConnectionString); })); elsa.UseWorkflowManagement(management => { management.UseEntityFrameworkCore(ef => { - //ef.UsePostgreSql(dbConnectionString); - ef.UseSqlServer(dbConnectionString); + ef.UsePostgreSql(dbConnectionString); + //ef.UseSqlServer(dbConnectionString); }); management.UseCache(); }); @@ -96,8 +96,8 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl { runtime.UseEntityFrameworkCore(ef => { - //ef.UsePostgreSql(dbConnectionString); - ef.UseSqlServer(dbConnectionString); + ef.UsePostgreSql(dbConnectionString); + //ef.UseSqlServer(dbConnectionString); }); runtime.UseCache(); runtime.UseDistributedRuntime(); @@ -114,8 +114,8 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl { alterations.UseEntityFrameworkCore(ef => { - //ef.UsePostgreSql(dbConnectionString); - ef.UseSqlServer(dbConnectionString); + ef.UsePostgreSql(dbConnectionString); + //ef.UseSqlServer(dbConnectionString); }); }); elsa.UseHttp(http => From 14189494b2883602d019865f6da715578c9927b5 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 11 Dec 2025 09:58:45 +0100 Subject: [PATCH 26/27] Switch component tests database configuration from PostgreSQL to SQL Server. --- .../Helpers/Fixtures/Infrastructure.cs | 8 ++++---- .../Helpers/Fixtures/WorkflowServer.cs | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs index 8e2dd1519..6b67c2bf8 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs @@ -6,11 +6,11 @@ namespace Elsa.Workflows.ComponentTests.Fixtures; public class Infrastructure : IAsyncLifetime { - public readonly PostgreSqlContainer DbContainer = new PostgreSqlBuilder().Build(); + //public readonly PostgreSqlContainer DbContainer = new PostgreSqlBuilder().Build(); - // public readonly MsSqlContainer DbContainer = new MsSqlBuilder() - // .WithImage("mcr.microsoft.com/mssql/server:2022-latest") - // .Build(); + public readonly MsSqlContainer DbContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-CU13-ubuntu-22.04") + .Build(); public readonly RabbitMqContainer RabbitMqContainer = new RabbitMqBuilder() .WithImage("rabbitmq:4-management") diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index c07fcc0f9..1a10ecba1 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -80,15 +80,15 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl }); elsa.UseIdentity(identity => identity.UseEntityFrameworkCore(ef => { - ef.UsePostgreSql(dbConnectionString); - //ef.UseSqlServer(dbConnectionString); + //ef.UsePostgreSql(dbConnectionString); + ef.UseSqlServer(dbConnectionString); })); elsa.UseWorkflowManagement(management => { management.UseEntityFrameworkCore(ef => { - ef.UsePostgreSql(dbConnectionString); - //ef.UseSqlServer(dbConnectionString); + //ef.UsePostgreSql(dbConnectionString); + ef.UseSqlServer(dbConnectionString); }); management.UseCache(); }); @@ -96,8 +96,8 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl { runtime.UseEntityFrameworkCore(ef => { - ef.UsePostgreSql(dbConnectionString); - //ef.UseSqlServer(dbConnectionString); + //ef.UsePostgreSql(dbConnectionString); + ef.UseSqlServer(dbConnectionString); }); runtime.UseCache(); runtime.UseDistributedRuntime(); @@ -114,8 +114,8 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl { alterations.UseEntityFrameworkCore(ef => { - ef.UsePostgreSql(dbConnectionString); - //ef.UseSqlServer(dbConnectionString); + //ef.UsePostgreSql(dbConnectionString); + ef.UseSqlServer(dbConnectionString); }); }); elsa.UseHttp(http => From dd6bfbb6b0f40c6e611b44b75c416adea74d20b5 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 11 Dec 2025 10:05:39 +0100 Subject: [PATCH 27/27] Comment out SQL Server image configuration in component test infrastructure. --- .../Helpers/Fixtures/Infrastructure.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs index 6b67c2bf8..a254bbe89 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/Infrastructure.cs @@ -9,7 +9,7 @@ public class Infrastructure : IAsyncLifetime //public readonly PostgreSqlContainer DbContainer = new PostgreSqlBuilder().Build(); public readonly MsSqlContainer DbContainer = new MsSqlBuilder() - .WithImage("mcr.microsoft.com/mssql/server:2022-CU13-ubuntu-22.04") + //.WithImage("mcr.microsoft.com/mssql/server:2025-GA-ubuntu") .Build(); public readonly RabbitMqContainer RabbitMqContainer = new RabbitMqBuilder()