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