diff --git a/Elsa.sln b/Elsa.sln
index c1e044cb0..482794984 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -325,6 +325,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dsl", "dsl", "{477C2416-312
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core.UnitTests", "test\unit\Elsa.Resilience.Core.UnitTests\Elsa.Resilience.Core.UnitTests.csproj", "{B8006D70-1630-43DB-A043-FA89FAC70F37}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.UnitTests", "test\unit\Elsa.Common.UnitTests\Elsa.Common.UnitTests.csproj", "{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -585,6 +587,10 @@ Global
{B8006D70-1630-43DB-A043-FA89FAC70F37}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8006D70-1630-43DB-A043-FA89FAC70F37}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8006D70-1630-43DB-A043-FA89FAC70F37}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -687,6 +693,7 @@ Global
{477C2416-312D-46AE-BCD6-8FA1FAB43624} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{874F5A44-DB06-47AB-A18C-2D13942E0147} = {477C2416-312D-46AE-BCD6-8FA1FAB43624}
{B8006D70-1630-43DB-A043-FA89FAC70F37} = {18453B51-25EB-4317-A4B3-B10518252E92}
+ {A3C07D5B-2A30-494E-B9BC-4B1594B31ABC} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
diff --git a/src/modules/Elsa.Common/Attributes/TaskDependencyAttribute.cs b/src/modules/Elsa.Common/Attributes/TaskDependencyAttribute.cs
new file mode 100644
index 000000000..1ad6d03a6
--- /dev/null
+++ b/src/modules/Elsa.Common/Attributes/TaskDependencyAttribute.cs
@@ -0,0 +1,13 @@
+namespace Elsa.Common;
+
+///
+/// Specifies dependencies for a task implementation. Tasks with dependencies will be executed after their dependencies have completed.
+///
+[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
+public class TaskDependencyAttribute(Type dependencyTaskType) : Attribute
+{
+ ///
+ /// The type of the task that must complete before this task can execute.
+ ///
+ public Type DependencyTaskType { get; } = dependencyTaskType;
+}
diff --git a/src/modules/Elsa.Common/Features/MultitenancyFeature.cs b/src/modules/Elsa.Common/Features/MultitenancyFeature.cs
index 640e6183e..b23e0d50f 100644
--- a/src/modules/Elsa.Common/Features/MultitenancyFeature.cs
+++ b/src/modules/Elsa.Common/Features/MultitenancyFeature.cs
@@ -39,18 +39,12 @@ public class MultitenancyFeature(IModule module) : FeatureBase(module)
.AddSingleton()
.AddSingleton()
.AddSingleton()
-
- // Order is important: Startup task first, then background and recurring tasks.
- .AddSingleton()
-
- .AddSingleton()
- .AddSingleton(sp => sp.GetRequiredService())
- .AddSingleton(sp => sp.GetRequiredService())
-
- .AddSingleton()
- .AddSingleton(sp => sp.GetRequiredService())
- .AddSingleton(sp => sp.GetRequiredService())
-
+
+ // TenantTaskManager handles all task lifecycle in the correct order
+ .AddSingleton()
+ .AddSingleton(sp => sp.GetRequiredService())
+ .AddSingleton(sp => sp.GetRequiredService())
+
.AddSingleton()
.AddSingleton()
.AddScoped()
diff --git a/src/modules/Elsa.Common/Helpers/TopologicalTaskSorter.cs b/src/modules/Elsa.Common/Helpers/TopologicalTaskSorter.cs
new file mode 100644
index 000000000..612244813
--- /dev/null
+++ b/src/modules/Elsa.Common/Helpers/TopologicalTaskSorter.cs
@@ -0,0 +1,93 @@
+using System.Reflection;
+
+namespace Elsa.Common.Helpers;
+
+///
+/// Sorts tasks based on their dependencies using topological ordering.
+///
+public static class TopologicalTaskSorter
+{
+ ///
+ /// Sorts tasks in topological order based on their TaskDependencyAttribute declarations.
+ ///
+ /// The tasks to sort.
+ /// The task type.
+ /// A list of tasks sorted in dependency order.
+ /// Thrown when a circular dependency is detected.
+ public static IReadOnlyList Sort(IEnumerable tasks) where T : ITask
+ {
+ var taskList = tasks.ToList();
+ var taskTypes = taskList.Select(t => t.GetType()).ToList();
+ var dependencyGraph = BuildDependencyGraph(taskTypes);
+ var sortedTypes = TopologicalSort(dependencyGraph);
+
+ // Map sorted types back to original task instances, preserving multiples
+ var taskGroups = taskList.GroupBy(t => t.GetType()).ToDictionary(g => g.Key, g => g.ToList());
+ var result = new List();
+
+ foreach (var type in sortedTypes.Where(taskGroups.ContainsKey))
+ {
+ var group = taskGroups[type];
+ result.AddRange(group);
+ }
+
+ return result;
+ }
+
+ private static Dictionary> BuildDependencyGraph(IEnumerable taskTypes)
+ {
+ var graph = new Dictionary>();
+
+ foreach (var taskType in taskTypes)
+ {
+ if (!graph.ContainsKey(taskType))
+ graph[taskType] = new();
+
+ var dependencies = taskType.GetCustomAttributes()
+ .Select(attr => attr.DependencyTaskType)
+ .ToList();
+
+ graph[taskType].AddRange(dependencies);
+ }
+
+ return graph;
+ }
+
+ private static List TopologicalSort(Dictionary> graph)
+ {
+ var sorted = new List();
+ var visited = new HashSet();
+ var visiting = new HashSet();
+
+ foreach (var node in graph.Keys)
+ {
+ if (!visited.Contains(node))
+ Visit(node, graph, visited, visiting, sorted);
+ }
+
+ return sorted;
+ }
+
+ private static void Visit(Type node, Dictionary> graph, HashSet visited, HashSet visiting, List sorted)
+ {
+ if (visiting.Contains(node))
+ throw new InvalidOperationException($"Circular dependency detected involving task type: {node.Name}");
+
+ if (visited.Contains(node))
+ return;
+
+ visiting.Add(node);
+
+ if (graph.TryGetValue(node, out var dependencies))
+ {
+ foreach (var dependency in dependencies)
+ {
+ Visit(dependency, graph, visited, visiting, sorted);
+ }
+ }
+
+ visiting.Remove(node);
+ visited.Add(node);
+ sorted.Add(node);
+ }
+}
diff --git a/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantScope.cs b/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantScope.cs
new file mode 100644
index 000000000..9bf8dba04
--- /dev/null
+++ b/src/modules/Elsa.Common/Multitenancy/Contracts/ITenantScope.cs
@@ -0,0 +1,9 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Elsa.Common.Multitenancy;
+
+public interface ITenantScope
+{
+ public IServiceScope ServiceScope { get; }
+ IServiceProvider ServiceProvider { get; }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Common/Multitenancy/EventHandlers/RunBackgroundTasks.cs b/src/modules/Elsa.Common/Multitenancy/EventHandlers/RunBackgroundTasks.cs
deleted file mode 100644
index 8548156dc..000000000
--- a/src/modules/Elsa.Common/Multitenancy/EventHandlers/RunBackgroundTasks.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Elsa.Common.Multitenancy.EventHandlers;
-
-public class RunBackgroundTasks : ITenantActivatedEvent, ITenantDeactivatedEvent
-{
- private readonly ICollection _runningTasks = new List();
- private CancellationTokenSource _cancellationTokenSource = default!;
-
- public Task TenantActivatedAsync(TenantActivatedEventArgs args)
- {
- var cancellationToken = args.CancellationToken;
- _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
-
- var tenantScope = args.TenantScope;
- var backgroundTasks = tenantScope.ServiceProvider.GetServices();
- var backgroundTaskStarter = tenantScope.ServiceProvider.GetRequiredService();
- var taskExecutor = tenantScope.ServiceProvider.GetRequiredService();
-
- foreach (var backgroundTask in backgroundTasks)
- {
- var task = backgroundTaskStarter
- .StartAsync(backgroundTask, _cancellationTokenSource.Token)
- .ContinueWith(t => taskExecutor.ExecuteTaskAsync(backgroundTask, _cancellationTokenSource.Token), cancellationToken, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
- if (!task.IsCompleted) _runningTasks.Add(task);
- }
-
- return Task.CompletedTask;
- }
-
- public Task TenantDeactivatedAsync(TenantDeactivatedEventArgs args)
- {
- _cancellationTokenSource.Cancel();
- _runningTasks.Clear();
- return Task.CompletedTask;
- }
-}
\ No newline at end of file
diff --git a/src/modules/Elsa.Common/Multitenancy/EventHandlers/RunStartupTasks.cs b/src/modules/Elsa.Common/Multitenancy/EventHandlers/RunStartupTasks.cs
deleted file mode 100644
index 0bcca5c03..000000000
--- a/src/modules/Elsa.Common/Multitenancy/EventHandlers/RunStartupTasks.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Reflection;
-using Elsa.Common.RecurringTasks;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Elsa.Common.Multitenancy.EventHandlers;
-
-public class RunStartupTasks : ITenantActivatedEvent
-{
- public async Task TenantActivatedAsync(TenantActivatedEventArgs args)
- {
- var cancellationToken = args.CancellationToken;
- var tenantScope = args.TenantScope;
- var tasks = tenantScope.ServiceProvider.GetServices().OrderBy(x => x.GetType().GetCustomAttribute()?.Order ?? 0f).ToList();
- var taskExecutor = tenantScope.ServiceProvider.GetRequiredService();
-
- foreach (var task in tasks)
- await taskExecutor.ExecuteTaskAsync(task, cancellationToken);
- }
-}
\ No newline at end of file
diff --git a/src/modules/Elsa.Common/Multitenancy/EventHandlers/StartRecurringTasks.cs b/src/modules/Elsa.Common/Multitenancy/EventHandlers/StartRecurringTasks.cs
deleted file mode 100644
index f83479af9..000000000
--- a/src/modules/Elsa.Common/Multitenancy/EventHandlers/StartRecurringTasks.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using Elsa.Common.RecurringTasks;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace Elsa.Common.Multitenancy.EventHandlers;
-
-public class StartRecurringTasks(RecurringTaskScheduleManager scheduleManager, ILogger logger) : ITenantActivatedEvent, ITenantDeactivatedEvent
-{
- private readonly ICollection _scheduledTimers = new List();
- private CancellationTokenSource _cancellationTokenSource = null!;
-
- public async Task TenantActivatedAsync(TenantActivatedEventArgs args)
- {
- var cancellationToken = args.CancellationToken;
- _cancellationTokenSource = new CancellationTokenSource();
- var tenantScope = args.TenantScope;
- var tasks = tenantScope.ServiceProvider.GetServices().ToList();
- var taskExecutor = tenantScope.ServiceProvider.GetRequiredService();
-
- foreach (var task in tasks)
- {
- var schedule = scheduleManager.GetScheduleFor(task.GetType());
- var timer = schedule.CreateTimer(async () =>
- {
- try
- {
- await taskExecutor.ExecuteTaskAsync(task, _cancellationTokenSource.Token);
- }
- catch (OperationCanceledException e)
- {
- logger.LogInformation(e, "Task {TaskType} was cancelled", task.GetType().Name);
- }
-
- });
- _scheduledTimers.Add(timer);
- await task.StartAsync(cancellationToken);
- }
- }
-
- public async Task TenantDeactivatedAsync(TenantDeactivatedEventArgs args)
- {
- var tenantScope = args.TenantScope;
- _cancellationTokenSource.Cancel();
- foreach (var timer in _scheduledTimers) await timer.DisposeAsync();
- _scheduledTimers.Clear();
- var tasks = tenantScope.ServiceProvider.GetServices();
- foreach (var task in tasks) await task.StopAsync(args.CancellationToken);
- }
-}
\ No newline at end of file
diff --git a/src/modules/Elsa.Common/Multitenancy/EventHandlers/TenantTaskManager.cs b/src/modules/Elsa.Common/Multitenancy/EventHandlers/TenantTaskManager.cs
new file mode 100644
index 000000000..1c8867f0a
--- /dev/null
+++ b/src/modules/Elsa.Common/Multitenancy/EventHandlers/TenantTaskManager.cs
@@ -0,0 +1,127 @@
+using System.Reflection;
+using Elsa.Common.Helpers;
+using Elsa.Common.RecurringTasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Elsa.Common.Multitenancy.EventHandlers;
+
+///
+/// Manages the lifecycle of startup, background, and recurring tasks for tenants.
+/// Executes tasks in the proper sequence: startup tasks first, then background tasks, then recurring tasks.
+///
+public class TenantTaskManager(RecurringTaskScheduleManager scheduleManager, ILogger logger) : ITenantActivatedEvent, ITenantDeactivatedEvent
+{
+ private readonly ICollection _runningBackgroundTasks = new List();
+ private readonly ICollection _scheduledTimers = new List();
+ private CancellationTokenSource _cancellationTokenSource = null!;
+
+ public async Task TenantActivatedAsync(TenantActivatedEventArgs args)
+ {
+ var cancellationToken = args.CancellationToken;
+ var tenantScope = args.TenantScope;
+ var taskExecutor = tenantScope.ServiceProvider.GetRequiredService();
+
+ _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+
+ // Step 1: Run startup tasks (with dependency ordering)
+ await RunStartupTasksAsync(tenantScope, taskExecutor, cancellationToken);
+
+ // Step 2: Run background tasks
+ await RunBackgroundTasksAsync(tenantScope, taskExecutor, cancellationToken);
+
+ // Step 3: Start recurring tasks
+ await StartRecurringTasksAsync(tenantScope, taskExecutor, cancellationToken);
+ }
+
+ public async Task TenantDeactivatedAsync(TenantDeactivatedEventArgs args)
+ {
+ var tenantScope = args.TenantScope;
+
+ // Cancel all running tasks
+ _cancellationTokenSource.Cancel();
+
+ // Wait for background tasks to complete (with cancellation they should finish quickly)
+ if (_runningBackgroundTasks.Any())
+ {
+ try
+ {
+ await Task.WhenAll(_runningBackgroundTasks);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when tasks are cancelled
+ }
+ _runningBackgroundTasks.Clear();
+ }
+
+ // Stop all recurring task timers
+ foreach (var timer in _scheduledTimers)
+ await timer.DisposeAsync();
+ _scheduledTimers.Clear();
+
+ // Stop recurring tasks
+ var recurringTasks = tenantScope.ServiceProvider.GetServices();
+ foreach (var task in recurringTasks)
+ await task.StopAsync(args.CancellationToken);
+ }
+
+ private async Task RunStartupTasksAsync(ITenantScope tenantScope, ITaskExecutor taskExecutor, CancellationToken cancellationToken)
+ {
+ var startupTasks = tenantScope.ServiceProvider.GetServices()
+ .OrderBy(x => x.GetType().GetCustomAttribute()?.Order ?? 0f)
+ .ToList();
+
+ // First apply OrderAttribute to determine a base order, then perform topological sorting.
+ // The topological sort is the final ordering step to ensure dependency constraints are respected.
+ var sortedTasks = TopologicalTaskSorter.Sort(startupTasks).ToList();
+ foreach (var task in sortedTasks)
+ await taskExecutor.ExecuteTaskAsync(task, cancellationToken);
+ }
+
+ private Task RunBackgroundTasksAsync(ITenantScope tenantScope, ITaskExecutor taskExecutor, CancellationToken cancellationToken)
+ {
+ var backgroundTasks = tenantScope.ServiceProvider.GetServices();
+ var backgroundTaskStarter = tenantScope.ServiceProvider.GetRequiredService();
+
+ foreach (var backgroundTask in backgroundTasks)
+ {
+ var task = backgroundTaskStarter
+ .StartAsync(backgroundTask, _cancellationTokenSource.Token)
+ .ContinueWith(t => taskExecutor.ExecuteTaskAsync(backgroundTask, _cancellationTokenSource.Token),
+ cancellationToken,
+ TaskContinuationOptions.RunContinuationsAsynchronously,
+ TaskScheduler.Default)
+ .Unwrap();
+
+ if (!task.IsCompleted)
+ _runningBackgroundTasks.Add(task);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private async Task StartRecurringTasksAsync(ITenantScope tenantScope, ITaskExecutor taskExecutor, CancellationToken cancellationToken)
+ {
+ var recurringTasks = tenantScope.ServiceProvider.GetServices().ToList();
+
+ foreach (var task in recurringTasks)
+ {
+ var schedule = scheduleManager.GetScheduleFor(task.GetType());
+ var timer = schedule.CreateTimer(async () =>
+ {
+ try
+ {
+ await taskExecutor.ExecuteTaskAsync(task, _cancellationTokenSource.Token);
+ }
+ catch (OperationCanceledException e)
+ {
+ logger.LogInformation(e, "Recurring task {TaskType} was cancelled", task.GetType().Name);
+ }
+ });
+
+ _scheduledTimers.Add(timer);
+ await task.StartAsync(cancellationToken);
+ }
+ }
+}
diff --git a/src/modules/Elsa.Common/Multitenancy/Models/TenantScope.cs b/src/modules/Elsa.Common/Multitenancy/Models/TenantScope.cs
index 33d6fb260..42396476a 100644
--- a/src/modules/Elsa.Common/Multitenancy/Models/TenantScope.cs
+++ b/src/modules/Elsa.Common/Multitenancy/Models/TenantScope.cs
@@ -6,7 +6,7 @@ namespace Elsa.Common.Multitenancy;
/// Represents a tenant scope, which sets the current tenant for the duration of the scope.
/// After the scope is disposed, the original tenant is restored.
///
-public class TenantScope(IServiceScope serviceScope, ITenantAccessor tenantAccessor, Tenant? tenant) : IAsyncDisposable
+public class TenantScope(IServiceScope serviceScope, ITenantAccessor tenantAccessor, Tenant? tenant) : ITenantScope, IAsyncDisposable
{
private readonly IDisposable _tenantContext = tenantAccessor.PushContext(tenant);
diff --git a/src/modules/Elsa.Tenants/Providers/ConfigurationTenantsProvider.cs b/src/modules/Elsa.Tenants/Providers/ConfigurationTenantsProvider.cs
index 80d272edb..68884a2fa 100644
--- a/src/modules/Elsa.Tenants/Providers/ConfigurationTenantsProvider.cs
+++ b/src/modules/Elsa.Tenants/Providers/ConfigurationTenantsProvider.cs
@@ -48,7 +48,7 @@ public class ConfigurationTenantsProvider : ITenantsProvider
var tenants = options.Tenants.ToList();
// Rebind each Tenant's Configuration property manually using array indices
- for (int i = 0; i < tenants.Count; i++)
+ for (var i = 0; i < tenants.Count; i++)
tenants[i].Configuration = _configuration.GetSection($"Multitenancy:Tenants:{i}:Configuration");
_tenants = tenants;
diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs
index 144497879..96693d4e5 100644
--- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs
+++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs
@@ -701,13 +701,7 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// True if the memory block exists, false otherwise.
public bool TryGet(MemoryBlockReference blockReference, out object? value)
{
- // Handle Literal references directly - they hold their value and don't need to be in the memory register
- if (blockReference is Literal literal)
- {
- value = literal.Value;
- return true;
- }
-
+ // First, try to get the value from the memory register
var memoryBlock = GetMemoryBlock(blockReference);
if (memoryBlock != null)
@@ -716,6 +710,13 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
return true;
}
+ // Handle Literal references as a fallback - they can hold their value directly
+ if (blockReference is Literal literal)
+ {
+ value = literal.Value;
+ return true;
+ }
+
value = null;
return false;
}
diff --git a/src/modules/Elsa.Workflows.Core/Models/Output.cs b/src/modules/Elsa.Workflows.Core/Models/Output.cs
index dddced7b5..c20b8f434 100644
--- a/src/modules/Elsa.Workflows.Core/Models/Output.cs
+++ b/src/modules/Elsa.Workflows.Core/Models/Output.cs
@@ -4,7 +4,7 @@ namespace Elsa.Workflows.Models;
public class Output : Argument
{
- public Output() : base(new Literal())
+ public Output() : base(new MemoryBlockReference())
{
}
diff --git a/test/unit/Elsa.Common.UnitTests/Elsa.Common.UnitTests.csproj b/test/unit/Elsa.Common.UnitTests/Elsa.Common.UnitTests.csproj
new file mode 100644
index 000000000..20987e158
--- /dev/null
+++ b/test/unit/Elsa.Common.UnitTests/Elsa.Common.UnitTests.csproj
@@ -0,0 +1,13 @@
+
+
+
+ [Elsa.Common]*
+ 0
+
+
+
+
+
+
+
+
diff --git a/test/unit/Elsa.Common.UnitTests/Helpers/TopologicalTaskSorterTests.cs b/test/unit/Elsa.Common.UnitTests/Helpers/TopologicalTaskSorterTests.cs
new file mode 100644
index 000000000..d800f84ea
--- /dev/null
+++ b/test/unit/Elsa.Common.UnitTests/Helpers/TopologicalTaskSorterTests.cs
@@ -0,0 +1,281 @@
+using Elsa.Common.Helpers;
+
+namespace Elsa.Common.UnitTests.Helpers;
+
+public class TopologicalTaskSorterTests
+{
+ // Task with single dependency
+ [TaskDependency(typeof(TaskA))]
+ private class TaskWithDependency : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ // Task with multiple dependencies
+ [TaskDependency(typeof(TaskA))]
+ [TaskDependency(typeof(TaskB))]
+ private class TaskWithMultipleDependencies : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ // Chain of dependencies: TaskChainC -> TaskChainB -> TaskChainA
+ private class TaskChainA : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ [TaskDependency(typeof(TaskChainA))]
+ private class TaskChainB : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ [TaskDependency(typeof(TaskChainB))]
+ private class TaskChainC : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ // Circular dependency: TaskCircular1 -> TaskCircular2 -> TaskCircular1
+ [TaskDependency(typeof(TaskCircular2))]
+ private class TaskCircular1 : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ [TaskDependency(typeof(TaskCircular1))]
+ private class TaskCircular2 : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ // Self-referencing circular dependency
+ [TaskDependency(typeof(TaskSelfCircular))]
+ private class TaskSelfCircular : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ [Fact]
+ public void Sort_WithNoTasks_ReturnsEmptyList()
+ {
+ // Arrange
+ var tasks = Array.Empty();
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Sort_WithSingleTask_ReturnsSingleTask()
+ {
+ // Arrange
+ var tasks = new ITask[] { new TaskA() };
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Single(result);
+ Assert.IsType(result[0]);
+ }
+
+ [Fact]
+ public void Sort_WithNoDependencies_ReturnsAllTasks()
+ {
+ // Arrange
+ var taskA = new TaskA();
+ var taskB = new TaskB();
+ var taskC = new TaskC();
+ var tasks = new ITask[] { taskA, taskB, taskC };
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Equal(3, result.Count);
+ Assert.Contains(taskA, result);
+ Assert.Contains(taskB, result);
+ Assert.Contains(taskC, result);
+ }
+
+ [Fact]
+ public void Sort_WithSingleDependency_OrdersCorrectly()
+ {
+ // Arrange
+ var taskA = new TaskA();
+ var taskWithDependency = new TaskWithDependency();
+ var tasks = new ITask[] { taskWithDependency, taskA };
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.IsType(result[0]);
+ Assert.IsType(result[1]);
+ }
+
+ [Fact]
+ public void Sort_WithMultipleDependencies_OrdersCorrectly()
+ {
+ // Arrange
+ var taskA = new TaskA();
+ var taskB = new TaskB();
+ var taskWithMultipleDeps = new TaskWithMultipleDependencies();
+ var tasks = new ITask[] { taskWithMultipleDeps, taskB, taskA };
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Equal(3, result.Count);
+ var resultList = result.ToList();
+ var dependentIndex = resultList.IndexOf(taskWithMultipleDeps);
+ var taskAIndex = resultList.IndexOf(taskA);
+ var taskBIndex = resultList.IndexOf(taskB);
+
+ // Both dependencies should come before the dependent task
+ Assert.True(taskAIndex < dependentIndex);
+ Assert.True(taskBIndex < dependentIndex);
+ }
+
+ [Fact]
+ public void Sort_WithChainedDependencies_OrdersCorrectly()
+ {
+ // Arrange
+ var taskChainA = new TaskChainA();
+ var taskChainB = new TaskChainB();
+ var taskChainC = new TaskChainC();
+ var tasks = new ITask[] { taskChainC, taskChainA, taskChainB };
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Equal(3, result.Count);
+ Assert.IsType(result[0]);
+ Assert.IsType(result[1]);
+ Assert.IsType(result[2]);
+ }
+
+ [Fact]
+ public void Sort_WithCircularDependency_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var task1 = new TaskCircular1();
+ var task2 = new TaskCircular2();
+ var tasks = new ITask[] { task1, task2 };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => TopologicalTaskSorter.Sort(tasks));
+ Assert.Contains("Circular dependency detected", exception.Message);
+ }
+
+ [Fact]
+ public void Sort_WithSelfCircularDependency_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var task = new TaskSelfCircular();
+ var tasks = new ITask[] { task };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => TopologicalTaskSorter.Sort(tasks));
+ Assert.Contains("Circular dependency detected", exception.Message);
+ }
+
+ [Fact]
+ public void Sort_WithMultipleInstancesOfSameType_PreservesAllInstances()
+ {
+ // Arrange
+ var taskA1 = new TaskA();
+ var taskA2 = new TaskA();
+ var taskA3 = new TaskA();
+ var tasks = new ITask[] { taskA1, taskA2, taskA3 };
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Equal(3, result.Count);
+ Assert.Contains(taskA1, result);
+ Assert.Contains(taskA2, result);
+ Assert.Contains(taskA3, result);
+ }
+
+ [Fact]
+ public void Sort_WithMultipleInstancesOfSameTypeWithDependencies_OrdersCorrectly()
+ {
+ // Arrange
+ var taskA1 = new TaskA();
+ var taskA2 = new TaskA();
+ var taskWithDep1 = new TaskWithDependency();
+ var taskWithDep2 = new TaskWithDependency();
+ var tasks = new ITask[] { taskWithDep1, taskA1, taskWithDep2, taskA2 };
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Equal(4, result.Count);
+
+ // All TaskA instances should come before TaskWithDependency instances
+ var resultList = result.ToList();
+ var firstTaskAIndex = resultList.IndexOf(taskA1);
+ var secondTaskAIndex = resultList.IndexOf(taskA2);
+ var firstTaskWithDepIndex = resultList.IndexOf(taskWithDep1);
+ var secondTaskWithDepIndex = resultList.IndexOf(taskWithDep2);
+
+ Assert.True(firstTaskAIndex < firstTaskWithDepIndex);
+ Assert.True(firstTaskAIndex < secondTaskWithDepIndex);
+ Assert.True(secondTaskAIndex < firstTaskWithDepIndex);
+ Assert.True(secondTaskAIndex < secondTaskWithDepIndex);
+ }
+
+ [Fact]
+ public void Sort_WithMixedDependenciesAndIndependentTasks_OrdersCorrectly()
+ {
+ // Arrange
+ var taskA = new TaskA();
+ var taskB = new TaskB();
+ var taskC = new TaskC();
+ var taskWithDep = new TaskWithDependency();
+ var tasks = new ITask[] { taskC, taskWithDep, taskB, taskA };
+
+ // Act
+ var result = TopologicalTaskSorter.Sort(tasks);
+
+ // Assert
+ Assert.Equal(4, result.Count);
+
+ // TaskA must come before TaskWithDependency
+ var resultList = result.ToList();
+ var taskAIndex = resultList.IndexOf(taskA);
+ var taskWithDepIndex = resultList.IndexOf(taskWithDep);
+ Assert.True(taskAIndex < taskWithDepIndex);
+
+ // TaskB and TaskC can be anywhere (no dependencies)
+ Assert.Contains(taskB, result);
+ Assert.Contains(taskC, result);
+ }
+
+ // Test task classes without dependencies
+ private class TaskA : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ private class TaskB : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ private class TaskC : ITask
+ {
+ public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+}