Improves tenant task management with dependencies (#7174)

* Consolidate tenant task lifecycle logic into `TenantTaskManager` and remove obsolete task event handlers (`RunStartupTasks`, `RunBackgroundTasks`, `StartRecurringTasks`). Introduce `TopologicalTaskSorter` for dependency-based task execution.

* Handles multiple tasks of the same type

Updates the topological task sorter to handle multiple tasks of the same type.

Previously, the sorter assumed a one-to-one mapping between task types and task instances, which caused issues when multiple tasks of the same type were present.
Now, it groups tasks by type and adds them to the result in the correct order.

* Add unit tests for `TopologicalTaskSorter`

Introduce `Elsa.Common.UnitTests` project with comprehensive test coverage for `TopologicalTaskSorter`, including dependency resolution, circular dependency handling, and task ordering scenarios. Update `Elsa.sln` to include the new test project.

* Update src/modules/Elsa.Common/Multitenancy/EventHandlers/TenantTaskManager.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Unwrap background task continuation in `TenantTaskManager` to ensure proper task execution tracking.

* Update src/modules/Elsa.Common/Helpers/TopologicalTaskSorter.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor `TryGet` method to prioritize memory register lookup and update `Output` constructor to use `MemoryBlockReference`.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-12-29 20:49:24 +01:00 committed by GitHub
parent fa798b0a47
commit b577279321
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 560 additions and 127 deletions

View file

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

View file

@ -0,0 +1,13 @@
namespace Elsa.Common;
/// <summary>
/// Specifies dependencies for a task implementation. Tasks with dependencies will be executed after their dependencies have completed.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class TaskDependencyAttribute(Type dependencyTaskType) : Attribute
{
/// <summary>
/// The type of the task that must complete before this task can execute.
/// </summary>
public Type DependencyTaskType { get; } = dependencyTaskType;
}

View file

@ -39,18 +39,12 @@ public class MultitenancyFeature(IModule module) : FeatureBase(module)
.AddSingleton<ITenantAccessor, DefaultTenantAccessor>()
.AddSingleton<ITenantFinder, DefaultTenantFinder>()
.AddSingleton<ITenantService, DefaultTenantService>()
// Order is important: Startup task first, then background and recurring tasks.
.AddSingleton<ITenantActivatedEvent, RunStartupTasks>()
.AddSingleton<RunBackgroundTasks>()
.AddSingleton<ITenantActivatedEvent>(sp => sp.GetRequiredService<RunBackgroundTasks>())
.AddSingleton<ITenantDeactivatedEvent>(sp => sp.GetRequiredService<RunBackgroundTasks>())
.AddSingleton<StartRecurringTasks>()
.AddSingleton<ITenantActivatedEvent>(sp => sp.GetRequiredService<StartRecurringTasks>())
.AddSingleton<ITenantDeactivatedEvent>(sp => sp.GetRequiredService<StartRecurringTasks>())
// TenantTaskManager handles all task lifecycle in the correct order
.AddSingleton<TenantTaskManager>()
.AddSingleton<ITenantActivatedEvent>(sp => sp.GetRequiredService<TenantTaskManager>())
.AddSingleton<ITenantDeactivatedEvent>(sp => sp.GetRequiredService<TenantTaskManager>())
.AddSingleton<RecurringTaskScheduleManager>()
.AddSingleton<TenantEventsManager>()
.AddScoped<DefaultTenantsProvider>()

View file

@ -0,0 +1,93 @@
using System.Reflection;
namespace Elsa.Common.Helpers;
/// <summary>
/// Sorts tasks based on their dependencies using topological ordering.
/// </summary>
public static class TopologicalTaskSorter
{
/// <summary>
/// Sorts tasks in topological order based on their TaskDependencyAttribute declarations.
/// </summary>
/// <param name="tasks">The tasks to sort.</param>
/// <typeparam name="T">The task type.</typeparam>
/// <returns>A list of tasks sorted in dependency order.</returns>
/// <exception cref="InvalidOperationException">Thrown when a circular dependency is detected.</exception>
public static IReadOnlyList<T> Sort<T>(IEnumerable<T> 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<T>();
foreach (var type in sortedTypes.Where(taskGroups.ContainsKey))
{
var group = taskGroups[type];
result.AddRange(group);
}
return result;
}
private static Dictionary<Type, List<Type>> BuildDependencyGraph(IEnumerable<Type> taskTypes)
{
var graph = new Dictionary<Type, List<Type>>();
foreach (var taskType in taskTypes)
{
if (!graph.ContainsKey(taskType))
graph[taskType] = new();
var dependencies = taskType.GetCustomAttributes<TaskDependencyAttribute>()
.Select(attr => attr.DependencyTaskType)
.ToList();
graph[taskType].AddRange(dependencies);
}
return graph;
}
private static List<Type> TopologicalSort(Dictionary<Type, List<Type>> graph)
{
var sorted = new List<Type>();
var visited = new HashSet<Type>();
var visiting = new HashSet<Type>();
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<Type, List<Type>> graph, HashSet<Type> visited, HashSet<Type> visiting, List<Type> 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);
}
}

View file

@ -0,0 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Common.Multitenancy;
public interface ITenantScope
{
public IServiceScope ServiceScope { get; }
IServiceProvider ServiceProvider { get; }
}

View file

@ -1,37 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Common.Multitenancy.EventHandlers;
public class RunBackgroundTasks : ITenantActivatedEvent, ITenantDeactivatedEvent
{
private readonly ICollection<Task> _runningTasks = new List<Task>();
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<IBackgroundTask>();
var backgroundTaskStarter = tenantScope.ServiceProvider.GetRequiredService<IBackgroundTaskStarter>();
var taskExecutor = tenantScope.ServiceProvider.GetRequiredService<ITaskExecutor>();
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;
}
}

View file

@ -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<IStartupTask>().OrderBy(x => x.GetType().GetCustomAttribute<OrderAttribute>()?.Order ?? 0f).ToList();
var taskExecutor = tenantScope.ServiceProvider.GetRequiredService<ITaskExecutor>();
foreach (var task in tasks)
await taskExecutor.ExecuteTaskAsync(task, cancellationToken);
}
}

View file

@ -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<StartRecurringTasks> logger) : ITenantActivatedEvent, ITenantDeactivatedEvent
{
private readonly ICollection<ScheduledTimer> _scheduledTimers = new List<ScheduledTimer>();
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<IRecurringTask>().ToList();
var taskExecutor = tenantScope.ServiceProvider.GetRequiredService<ITaskExecutor>();
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<IRecurringTask>();
foreach (var task in tasks) await task.StopAsync(args.CancellationToken);
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public class TenantTaskManager(RecurringTaskScheduleManager scheduleManager, ILogger<TenantTaskManager> logger) : ITenantActivatedEvent, ITenantDeactivatedEvent
{
private readonly ICollection<Task> _runningBackgroundTasks = new List<Task>();
private readonly ICollection<ScheduledTimer> _scheduledTimers = new List<ScheduledTimer>();
private CancellationTokenSource _cancellationTokenSource = null!;
public async Task TenantActivatedAsync(TenantActivatedEventArgs args)
{
var cancellationToken = args.CancellationToken;
var tenantScope = args.TenantScope;
var taskExecutor = tenantScope.ServiceProvider.GetRequiredService<ITaskExecutor>();
_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<IRecurringTask>();
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<IStartupTask>()
.OrderBy(x => x.GetType().GetCustomAttribute<OrderAttribute>()?.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<IBackgroundTask>();
var backgroundTaskStarter = tenantScope.ServiceProvider.GetRequiredService<IBackgroundTaskStarter>();
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<IRecurringTask>().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);
}
}
}

View file

@ -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.
/// </summary>
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);

View file

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

View file

@ -701,13 +701,7 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// <returns>True if the memory block exists, false otherwise.</returns>
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;
}

View file

@ -4,7 +4,7 @@ namespace Elsa.Workflows.Models;
public class Output : Argument
{
public Output() : base(new Literal())
public Output() : base(new MemoryBlockReference())
{
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Common]*</Include>
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Common\Elsa.Common.csproj" />
</ItemGroup>
</Project>

View file

@ -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<ITask>();
// 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<TaskA>(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<TaskA>(result[0]);
Assert.IsType<TaskWithDependency>(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<TaskChainA>(result[0]);
Assert.IsType<TaskChainB>(result[1]);
Assert.IsType<TaskChainC>(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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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;
}
}