Merge pull request #6841 from elsa-workflows/feat/task-activity-attribute

Improving behaviour of Run Asynchronously property for the TaskActivity Attribute
This commit is contained in:
Lucas Hipólito 2025-08-06 12:37:47 +02:00 committed by GitHub
commit 6dfa5251df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 90 additions and 12 deletions

View file

@ -68,7 +68,7 @@ public abstract class Activity : IActivity, ISignalHandler
/// will execute synchronously, while activities of the <see cref="ActivityKind.Job"/> kind will execute asynchronously.
/// </summary>
[JsonIgnore]
public bool RunAsynchronously
public bool? RunAsynchronously
{
get => this.GetRunAsynchronously();
set => this.SetRunAsynchronously(value);

View file

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

View file

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

View file

@ -29,14 +29,17 @@ public static class ActivityPropertyExtensions
/// By default, activities with an <see cref="ActivityKind"/> of <see cref="Action"/>, <see cref="Task"/> or <see cref="Trigger"/>
/// will execute synchronously, while activities of the <see cref="ActivityKind.Job"/> kind will execute asynchronously.
/// </summary>
public static bool GetRunAsynchronously(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(RunAsynchronouslyPropertyName, () => false);
public static bool? GetRunAsynchronously(this IActivity activity)
{
return activity.CustomProperties.GetValueOrDefault<bool?>(RunAsynchronouslyPropertyName, defaultValueFactory: () => null);
}
/// <summary>
/// Sets a flag indicating if this activity should execute synchronously or asynchronously.
/// By default, activities with an <see cref="ActivityKind"/> of <see cref="Action"/>, <see cref="Task"/> or <see cref="Trigger"/>
/// will execute synchronously, while activities of the <see cref="ActivityKind.Job"/> kind will execute asynchronously.
/// </summary>
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;
/// <summary>
/// Gets the source file and line number where this activity was instantiated, if any.

View file

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

View file

@ -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<TaskActivityAttribute>().FirstOrDefault();
return taskActivityAttribute is { RunAsynchronously: true };
}
return (bool)runAsynchronously;
}
private static bool GetIsBackgroundExecution(ActivityExecutionContext context) => context.TransientProperties.ContainsKey(BackgroundActivityExecutionContextExtensions.IsBackgroundExecution);

View file

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

View file

@ -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<string>(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<string, object>
@ -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<string, object>
@ -166,6 +179,7 @@ public class Tests
{ "string", "str"},
}
};
container.RunAsynchronously = false;
// Act