From 1bf9b11ffc98afd622b091a166b4d30dd09b3eb3 Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Tue, 5 Aug 2025 13:50:35 +0200 Subject: [PATCH 1/4] Improving behaviour of Run Asynchronously property for the TaskActivityAttribute --- .../Abstractions/Activity.cs | 2 +- .../Extensions/ActivityPropertyExtensions.cs | 9 +++-- .../Services/ActivityFactory.cs | 34 ++++++++++++++++++- .../BackgroundActivityInvokerMiddleware.cs | 24 ++++++++++++- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs b/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs index 0ef11e6d8..1548daa0c 100644 --- a/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs +++ b/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs @@ -68,7 +68,7 @@ public abstract class Activity : IActivity, ISignalHandler /// will execute synchronously, while activities of the kind will execute asynchronously. /// [JsonIgnore] - public bool RunAsynchronously + public bool? RunAsynchronously { get => this.GetRunAsynchronously(); set => this.SetRunAsynchronously(value); diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs index ff9bee7c5..f82b56787 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs @@ -17,7 +17,7 @@ public static class ActivityPropertyExtensions /// Gets a flag indicating whether this activity can be used for starting a workflow. /// Usually used for triggers, but also used to disambiguate between two or more starting activities and no starting activity was specified. /// - public static bool GetCanStartWorkflow(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CanStartWorkflowPropertyName, () => false); + public static bool GetCanStartWorkflow(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CanStartWorkflowPropertyName, () => false); /// /// Sets a flag indicating whether this activity can be used for starting a workflow. @@ -29,14 +29,17 @@ public static class ActivityPropertyExtensions /// By default, activities with an of , or /// will execute synchronously, while activities of the kind will execute asynchronously. /// - public static bool GetRunAsynchronously(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(RunAsynchronouslyPropertyName, () => false); + public static bool? GetRunAsynchronously(this IActivity activity) + { + return activity.CustomProperties.GetValueOrDefault(RunAsynchronouslyPropertyName, defaultValueFactory: () => null); + } /// /// Sets a flag indicating if this activity should execute synchronously or asynchronously. /// By default, activities with an of , or /// will execute synchronously, while activities of the kind will execute asynchronously. /// - public static void SetRunAsynchronously(this IActivity activity, bool value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value; + public static void SetRunAsynchronously(this IActivity activity, bool? value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value; /// /// Gets the source file and line number where this activity was instantiated, if any. diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs index 081550a0d..6d5169efb 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using Elsa.Expressions.Helpers; using Elsa.Extensions; +using Elsa.Workflows.Attributes; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; using Humanizer; @@ -34,7 +35,18 @@ public class ActivityFactory : IActivityFactory // 7) Pull out your boolean flags from the cleaned element var canStartWorkflow = GetBoolean(cleanedElement, "canStartWorkflow"); - var runAsynchronously = GetBoolean(cleanedElement, "runAsynchronously"); + var runAsynchronously = GetNullableBoolean(cleanedElement, "runAsynchronously"); + if (runAsynchronously is null) + { + if (context.ActivityDescriptor.Attributes.OfType().FirstOrDefault() is { } taskActivityAttribute) + { + runAsynchronously = taskActivityAttribute.RunAsynchronously; + } + else + { + runAsynchronously = false; + } + } // 8) If composite, setup if (activity is IComposite composite) @@ -197,4 +209,24 @@ public class ActivityFactory : IActivityFactory return false; } + + private static bool? GetNullableBoolean(JsonElement element, string propertyName) + { + var propertyNames = new[] { propertyName.Camelize(), propertyName.Pascalize() }; + + foreach (var name in propertyNames) + { + if (element.TryGetProperty("customProperties", out var customPropertyElement)) + { + if (customPropertyElement.TryGetProperty(name, out var canStartWorkflowElement)) + return (bool?)canStartWorkflowElement.GetValue(); + } + + if (element.TryGetProperty(propertyName.Camelize(), out var property) + && (bool?)property.GetValue() is { } propValue) + return propValue; + } + + return null; + } } diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs index e744870a3..30277beb9 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Elsa.Extensions; using Elsa.Mediator.Contracts; +using Elsa.Workflows.Attributes; using Elsa.Workflows.CommitStates; using Elsa.Workflows.Middleware.Activities; using Elsa.Workflows.Models; @@ -103,7 +104,28 @@ public class BackgroundActivityInvokerMiddleware( return !GetIsBackgroundExecution(context) && context.WorkflowExecutionContext.ExecuteDelegate == null - && (kind is ActivityKind.Job || (kind == ActivityKind.Task && activity.GetRunAsynchronously())); + && (kind is ActivityKind.Job || GetTaskRunAsynchronously(context)); + } + + private static bool GetTaskRunAsynchronously(ActivityExecutionContext context) + { + var activity = context.Activity; + var activityDescriptor = context.ActivityDescriptor; + var kind = activityDescriptor.Kind; + + if (kind is not ActivityKind.Task) + return false; + + var runAsynchronously = activity.GetRunAsynchronously(); + + if (runAsynchronously is null) + { + var taskActivityAttribute = activityDescriptor.Attributes.OfType().FirstOrDefault(); + + return taskActivityAttribute is { RunAsynchronously: true }; + } + + return (bool)runAsynchronously; } private static bool GetIsBackgroundExecution(ActivityExecutionContext context) => context.TransientProperties.ContainsKey(BackgroundActivityExecutionContextExtensions.IsBackgroundExecution); From 3d0d1c7b5bf98b992faceed96f6e1ce0aa1da2a0 Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Tue, 5 Aug 2025 14:13:10 +0200 Subject: [PATCH 2/4] Added TaskActivityAttribute class --- .../Attributes/TaskActivityAttribute.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/modules/Elsa.Workflows.Core/Attributes/TaskActivityAttribute.cs diff --git a/src/modules/Elsa.Workflows.Core/Attributes/TaskActivityAttribute.cs b/src/modules/Elsa.Workflows.Core/Attributes/TaskActivityAttribute.cs new file mode 100644 index 000000000..390cfd628 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Attributes/TaskActivityAttribute.cs @@ -0,0 +1,19 @@ +namespace Elsa.Workflows.Attributes; + +[AttributeUsage(AttributeTargets.Class)] +public class TaskActivityAttribute : ActivityAttribute +{ + public TaskActivityAttribute() + { + Kind = ActivityKind.Task; + } + + public TaskActivityAttribute(string @namespace, string? category, string? description = null, bool runAsynchronously = false) + : base(@namespace, category, description) + { + Kind = ActivityKind.Task; + RunAsynchronously = runAsynchronously; + } + + public bool RunAsynchronously { get; set; } +} \ No newline at end of file From 977ce4b5292c364c08985e983f594104284b14d9 Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Wed, 6 Aug 2025 11:31:36 +0200 Subject: [PATCH 3/4] Small refactor for DRY principle + Fixing integration tests --- .../Serialization/FlowchartJsonConverter.cs | 4 +++- .../Extensions/ActivityPropertyExtensions.cs | 4 ++-- .../Services/ActivityFactory.cs | 16 +--------------- .../RunAsynchronousActivityOutput/Tests.cs | 4 ++-- .../ContainerSerialization/Tests.cs | 8 ++++++++ 5 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs index 4e0053ae4..b4658043a 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using Elsa.Expressions.Contracts; using Elsa.Extensions; @@ -28,6 +28,7 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK var name = doc.RootElement.TryGetProperty("name", out var nameElement) ? nameElement.GetString() : null; var type = doc.RootElement.TryGetProperty("type", out var typeElement) ? typeElement.GetString() : null; var version = doc.RootElement.TryGetProperty("version", out var versionElement) ? versionElement.GetInt32() : 1; + var runAsynchronously = doc.RootElement.TryGetProperty("runAsynchronously", out var runAsyncElement) && runAsyncElement.GetBoolean(); var connectionsElement = doc.RootElement.TryGetProperty("connections", out var connectionsEl) ? connectionsEl : default; var activitiesElement = doc.RootElement.TryGetProperty("activities", out var activitiesEl) ? activitiesEl : default; @@ -60,6 +61,7 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK NodeId = nodeId!, Name = name, Type = type!, + RunAsynchronously = runAsynchronously, Version = version, CustomProperties = customProperties, Metadata = metadata, diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs index f82b56787..938134cc1 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs @@ -17,7 +17,7 @@ public static class ActivityPropertyExtensions /// Gets a flag indicating whether this activity can be used for starting a workflow. /// Usually used for triggers, but also used to disambiguate between two or more starting activities and no starting activity was specified. /// - public static bool GetCanStartWorkflow(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CanStartWorkflowPropertyName, () => false); + public static bool GetCanStartWorkflow(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CanStartWorkflowPropertyName, () => false); /// /// Sets a flag indicating whether this activity can be used for starting a workflow. @@ -39,7 +39,7 @@ public static class ActivityPropertyExtensions /// By default, activities with an of , or /// will execute synchronously, while activities of the kind will execute asynchronously. /// - public static void SetRunAsynchronously(this IActivity activity, bool? value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value; + public static void SetRunAsynchronously(this IActivity activity, bool? value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value ?? false; /// /// Gets the source file and line number where this activity was instantiated, if any. diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs index 6d5169efb..4dbed1dc3 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs @@ -193,21 +193,7 @@ public class ActivityFactory : IActivityFactory private static bool GetBoolean(JsonElement element, string propertyName) { - var propertyNames = new[] { propertyName.Camelize(), propertyName.Pascalize() }; - - foreach (var name in propertyNames) - { - if (element.TryGetProperty("customProperties", out var customPropertyElement)) - { - if (customPropertyElement.TryGetProperty(name, out var canStartWorkflowElement)) - return canStartWorkflowElement.GetBoolean(); - } - - if (element.TryGetProperty(propertyName.Camelize(), out var property) && property.GetBoolean()) - return true; - } - - return false; + return GetNullableBoolean(element, propertyName) ?? false; } private static bool? GetNullableBoolean(JsonElement element, string propertyName) diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/RunAsynchronousActivityOutput/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/RunAsynchronousActivityOutput/Tests.cs index ffe94bfab..a5b37596c 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/RunAsynchronousActivityOutput/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/RunAsynchronousActivityOutput/Tests.cs @@ -14,7 +14,7 @@ public class Tests [Theory(DisplayName = "Activity outputs captured in activity execution record")] [InlineData(true)] [InlineData(false)] - public async Task ActivityOutputCaptureTest(bool runAsynchronously) + public async Task ActivityOutputCaptureTest(bool? runAsynchronously) { // Arrange var workflow = new TestWorkflow(workflowBuilder => @@ -76,7 +76,7 @@ public class Tests [Theory(DisplayName = "Activity outputs captured in activity execution record")] [InlineData(true)] [InlineData(false)] - public async Task ActivityOutputCaptureParallelTest(bool runAsynchronously) + public async Task ActivityOutputCaptureParallelTest(bool? runAsynchronously) { // Arrange var workflow = new TestWorkflow(workflowBuilder => diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs index ed02ac470..f49947e8d 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs @@ -32,17 +32,20 @@ public class Tests { Id = "start", Name = "Start", + RunAsynchronously = false }; var writeLine = new WriteLine(new Input(new Expression("JavaScript", "getVariable('TextVar')"))) { Id = "writeLine", Name = "WriteLine", Version = 3, + RunAsynchronously = false }; var end = new End { Id = "end", Name = "end", + RunAsynchronously = false }; var container = new Flowchart { @@ -74,6 +77,7 @@ public class Tests new(writeLine, end), }, }; + container.RunAsynchronously = false; // Act @@ -107,6 +111,7 @@ public class Tests Id = "writeLine", Name = "WriteLine", CanStartWorkflow = true, + RunAsynchronously = false }, }, CustomProperties = new Dictionary @@ -120,6 +125,7 @@ public class Tests { "string", "str"}, } }; + container.RunAsynchronously = false; // Act @@ -153,6 +159,7 @@ public class Tests Id = "writeLine", Name = "WriteLine", CanStartWorkflow = true, + RunAsynchronously = false }, }, CustomProperties = new Dictionary @@ -166,6 +173,7 @@ public class Tests { "string", "str"}, } }; + container.RunAsynchronously = false; // Act From 20d33f868297499db745579d506471fba0e344ae Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Wed, 6 Aug 2025 12:01:08 +0200 Subject: [PATCH 4/4] Explaining the reasoning for the test fixes with comments --- .../ContainerSerialization/Tests.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs index f49947e8d..e3f2c9716 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs @@ -32,20 +32,23 @@ public class Tests { Id = "start", Name = "Start", - RunAsynchronously = false + RunAsynchronously = false // Manually set to false because the manual construction defaults to null, + // But deserialization uses the factory creation method that overwrites null values. }; var writeLine = new WriteLine(new Input(new Expression("JavaScript", "getVariable('TextVar')"))) { Id = "writeLine", Name = "WriteLine", Version = 3, - RunAsynchronously = false + RunAsynchronously = false // Manually set to false because the manual construction defaults to null, + // But deserialization uses the factory creation method that overwrites null values. }; var end = new End { Id = "end", Name = "end", - RunAsynchronously = false + RunAsynchronously = false // Manually set to false because the manual construction defaults to null, + // But deserialization uses the factory creation method that overwrites null values. }; var container = new Flowchart { @@ -111,7 +114,8 @@ public class Tests Id = "writeLine", Name = "WriteLine", CanStartWorkflow = true, - RunAsynchronously = false + RunAsynchronously = false // Manually set to false because the manual construction defaults to null, + // But deserialization uses the factory creation method that overwrites null values. }, }, CustomProperties = new Dictionary @@ -159,7 +163,9 @@ public class Tests Id = "writeLine", Name = "WriteLine", CanStartWorkflow = true, - RunAsynchronously = false + RunAsynchronously = false // Manually set to false because the manual construction defaults to null, + // But deserialization uses the factory creation method that overwrites null values. + }, }, CustomProperties = new Dictionary