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/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/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 diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs index ff9bee7c5..938134cc1 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs @@ -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 ?? 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 081550a0d..4dbed1dc3 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) @@ -180,6 +192,11 @@ public class ActivityFactory : IActivityFactory } private static bool GetBoolean(JsonElement element, string propertyName) + { + return GetNullableBoolean(element, propertyName) ?? false; + } + + private static bool? GetNullableBoolean(JsonElement element, string propertyName) { var propertyNames = new[] { propertyName.Camelize(), propertyName.Pascalize() }; @@ -188,13 +205,14 @@ public class ActivityFactory : IActivityFactory if (element.TryGetProperty("customProperties", out var customPropertyElement)) { if (customPropertyElement.TryGetProperty(name, out var canStartWorkflowElement)) - return canStartWorkflowElement.GetBoolean(); + return (bool?)canStartWorkflowElement.GetValue(); } - if (element.TryGetProperty(propertyName.Camelize(), out var property) && property.GetBoolean()) - return true; + if (element.TryGetProperty(propertyName.Camelize(), out var property) + && (bool?)property.GetValue() is { } propValue) + return propValue; } - return false; + 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); 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..e3f2c9716 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs @@ -32,17 +32,23 @@ public class Tests { Id = "start", Name = "Start", + 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 // 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 // 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 { @@ -74,6 +80,7 @@ public class Tests new(writeLine, end), }, }; + container.RunAsynchronously = false; // Act @@ -107,6 +114,8 @@ public class Tests Id = "writeLine", Name = "WriteLine", CanStartWorkflow = true, + 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 @@ -120,6 +129,7 @@ public class Tests { "string", "str"}, } }; + container.RunAsynchronously = false; // Act @@ -153,6 +163,9 @@ public class Tests Id = "writeLine", Name = "WriteLine", CanStartWorkflow = true, + 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 @@ -166,6 +179,7 @@ public class Tests { "string", "str"}, } }; + container.RunAsynchronously = false; // Act