diff --git a/src/Cake.Core.Tests/Unit/CakeEngineTests.cs b/src/Cake.Core.Tests/Unit/CakeEngineTests.cs index 80060a4f34..8ada45e8f6 100644 --- a/src/Cake.Core.Tests/Unit/CakeEngineTests.cs +++ b/src/Cake.Core.Tests/Unit/CakeEngineTests.cs @@ -1558,6 +1558,126 @@ public async Task Should_Return_Report_That_Marks_Failed_Tasks_As_Failed() Assert.Equal(CakeTaskExecutionStatus.Delegated, report.First(e => e.TaskName == "A").ExecutionStatus); Assert.Equal(CakeTaskExecutionStatus.Failed, report.First(e => e.TaskName == "B").ExecutionStatus); } + + [Fact] + public async Task Should_Throw_Exception_For_Circular_Dependencies() + { + // Given + var fixture = new CakeEngineFixture(); + var settings = new ExecutionSettings().SetTarget("B"); + var engine = fixture.CreateEngine(); + engine.RegisterTask("B").IsDependentOn("C"); + engine.RegisterTask("C").IsDependentOn("D"); + engine.RegisterTask("D").IsDependentOn("B"); + + // When + var result = await Record.ExceptionAsync(() => engine.RunTargetAsync(fixture.Context, fixture.ExecutionStrategy, settings)); + + // Then + Assert.IsType(result); + Assert.Equal("Graph contains circular references.", result?.Message); + } + + [Fact] + public async Task Should_Throw_Exception_For_Circular_Dependencies_In_Parallel() + { + // Given + var fixture = new CakeEngineFixture(); + var settings = new ExecutionSettings().SetTarget("B").RunInParallel(); + var engine = fixture.CreateEngine(); + engine.RegisterTask("B").IsDependentOn("C"); + engine.RegisterTask("C").IsDependentOn("D"); + engine.RegisterTask("D").IsDependentOn("B"); + + // When + var result = await Record.ExceptionAsync(() => engine.RunTargetAsync(fixture.Context, fixture.ExecutionStrategy, settings)); + + // Then + Assert.IsType(result); + Assert.Equal("Graph contains circular references.", result?.Message); + } + + [Fact] + public async Task Should_Execute_Tasks_In_Order_In_Parallel() + { + // Given + var result = new List(); + var fixture = new CakeEngineFixture(); + var settings = new ExecutionSettings().SetTarget("E").RunInParallel(); + var engine = fixture.CreateEngine(); + engine.RegisterTask("A").Does(() => result.Add("A")); + engine.RegisterTask("B").IsDependentOn("A").Does(() => result.Add("B")); + engine.RegisterTask("C").IsDependentOn("B").Does(() => result.Add("C")); + engine.RegisterTask("D").IsDependentOn("C").IsDependeeOf("E").Does(() => { result.Add("D"); }); + engine.RegisterTask("E").Does(() => { result.Add("E"); }); + + // When + await engine.RunTargetAsync(fixture.Context, fixture.ExecutionStrategy, settings); + + // Then + Assert.Equal(5, result.Count); + Assert.Equal("A", result[0]); + Assert.Equal("B", result[1]); + Assert.Equal("C", result[2]); + Assert.Equal("D", result[3]); + Assert.Equal("E", result[4]); + } + + [Fact] + public async Task Should_Execute_Tasks_In_Parallel() + { + // Given + var result = new List(); + var fixture = new CakeEngineFixture(); + var settings = new ExecutionSettings().SetTarget("E").RunInParallel(); + var engine = fixture.CreateEngine(); + engine.RegisterTask("A").Does(() => result.Add("A")); + engine.RegisterTask("B").IsDependentOn("A").Does(async () => + { + await Task.Delay(20); + result.Add("B"); + }); + engine.RegisterTask("C").IsDependentOn("A").Does(async () => + { + await Task.Delay(5); + result.Add("C"); + }); + engine.RegisterTask("D").IsDependentOn("A").Does(() => result.Add("D")); + engine.RegisterTask("E").IsDependentOn("B").IsDependentOn("C").IsDependentOn("D").Does(() => result.Add("E")); + + // When + await engine.RunTargetAsync(fixture.Context, fixture.ExecutionStrategy, settings); + + // Then + Assert.Equal(5, result.Count); + Assert.Equal("A", result[0]); + Assert.Equal("D", result[1]); + Assert.Equal("C", result[2]); + Assert.Equal("B", result[3]); + Assert.Equal("E", result[4]); + } + + [Fact] + public async Task Should_Not_Catch_Exceptions_From_Task_If_ContinueOnError_Is_Not_Set_In_Parallel() + { + // Given + var fixture = new CakeEngineFixture(); + var settings = new ExecutionSettings().SetTarget("E").RunInParallel(); + var engine = fixture.CreateEngine(); + engine.RegisterTask("A"); + engine.RegisterTask("B").IsDependentOn("A"); + engine.RegisterTask("C").IsDependentOn("A").Does(() => throw new InvalidOperationException("Whoopsie")); + engine.RegisterTask("D").IsDependentOn("A"); + engine.RegisterTask("E").IsDependentOn("B").IsDependentOn("C").IsDependentOn("D"); + + // When + var result = await Record.ExceptionAsync(() => + engine.RunTargetAsync(fixture.Context, fixture.ExecutionStrategy, settings)); + + // Then + Assert.IsType(result); + Assert.Equal("Whoopsie", result?.Message); + } } public sealed class TheSetupEvent diff --git a/src/Cake.Core/CakeEngine.cs b/src/Cake.Core/CakeEngine.cs index ef2ff71e64..2227fae157 100644 --- a/src/Cake.Core/CakeEngine.cs +++ b/src/Cake.Core/CakeEngine.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Cake.Core.Diagnostics; using Cake.Core.Graph; @@ -211,19 +212,48 @@ public async Task RunTargetAsync(ICakeContext context, IExecutionStr { // Execute only the target task. var task = _tasks.FirstOrDefault(x => x.Name.Equals(settings.Target, StringComparison.OrdinalIgnoreCase)); - await RunTask(context, strategy, task, target, stopWatch, report); + await RunTask(context, strategy, task, target, stopWatch, report, null); + } + else if (settings.Parallel) + { + await graph.TraverseAsync(target, async (taskName, cancellationTokenSource) => + { + if (cancellationTokenSource.IsCancellationRequested) + { + return; + } + + var task = _tasks.FirstOrDefault(_ => _.Name.Equals(taskName, StringComparison.OrdinalIgnoreCase)); + Debug.Assert(task != null, "Node should not be null"); + + var isTarget = task.Name.Equals(target, StringComparison.OrdinalIgnoreCase); + + await RunTask(context, strategy, task, target, stopWatch, report, cancellationTokenSource); + }); } else { // Execute all scheduled tasks. foreach (var task in orderedTasks) { - await RunTask(context, strategy, task, target, stopWatch, report); + await RunTask(context, strategy, task, target, stopWatch, report, null); } } return report; } + catch (TaskCanceledException) + { + exceptionWasThrown = true; + throw; + } + catch (AggregateException ex) + { + exceptionWasThrown = true; + thrownException = ex.InnerException; + + throw ex.GetBaseException(); + } catch (Exception ex) { exceptionWasThrown = true; @@ -236,7 +266,7 @@ public async Task RunTargetAsync(ICakeContext context, IExecutionStr } } - private async Task RunTask(ICakeContext context, IExecutionStrategy strategy, CakeTask task, string target, Stopwatch stopWatch, CakeReport report) + private async Task RunTask(ICakeContext context, IExecutionStrategy strategy, CakeTask task, string target, Stopwatch stopWatch, CakeReport report, CancellationTokenSource cancellationTokenSource) { // Is this the current target? var isTarget = task.Name.Equals(target, StringComparison.OrdinalIgnoreCase); @@ -255,7 +285,7 @@ private async Task RunTask(ICakeContext context, IExecutionStrategy strategy, Ca if (!skipped) { - await ExecuteTaskAsync(context, strategy, stopWatch, task, report).ConfigureAwait(false); + await ExecuteTaskAsync(context, strategy, stopWatch, task, report, cancellationTokenSource).ConfigureAwait(false); } } @@ -309,7 +339,7 @@ private static bool ShouldTaskExecute(ICakeContext context, CakeTask task, CakeT } private async Task ExecuteTaskAsync(ICakeContext context, IExecutionStrategy strategy, Stopwatch stopWatch, - CakeTask task, CakeReport report) + CakeTask task, CakeReport report, CancellationTokenSource cancellationTokenSource) { stopWatch.Restart(); @@ -321,6 +351,11 @@ private async Task ExecuteTaskAsync(ICakeContext context, IExecutionStrategy str // Execute the task. await strategy.ExecuteAsync(task, context).ConfigureAwait(false); } + catch (TaskCanceledException exception) + { + taskException = exception; + throw; + } catch (Exception exception) { _log.Error("An error occurred when executing task '{0}'.", task.Name); @@ -340,6 +375,8 @@ private async Task ExecuteTaskAsync(ICakeContext context, IExecutionStrategy str } else { + cancellationTokenSource?.Cancel(); + // No error handler defined for this task. // Rethrow the exception and let it propagate. throw; diff --git a/src/Cake.Core/ExecutionSettings.cs b/src/Cake.Core/ExecutionSettings.cs index 80bd90f720..9421293175 100644 --- a/src/Cake.Core/ExecutionSettings.cs +++ b/src/Cake.Core/ExecutionSettings.cs @@ -19,6 +19,11 @@ public sealed class ExecutionSettings /// public bool Exclusive { get; private set; } + /// + /// Gets a value indicating whether the dependend task of the target should be run in parallel (if possible). + /// + public bool Parallel { get; private set; } + /// /// Sets the target to be executed. /// @@ -39,5 +44,15 @@ public ExecutionSettings UseExclusiveTarget() Exclusive = true; return this; } + + /// + /// Whether or not to run the dependend task in parallel. + /// + /// The same instance so that multiple calls can be chained. + public ExecutionSettings RunInParallel() + { + Parallel = true; + return this; + } } } \ No newline at end of file diff --git a/src/Cake.Core/Graph/CakeGraph.cs b/src/Cake.Core/Graph/CakeGraph.cs index 53d8062999..5e168f6f5c 100644 --- a/src/Cake.Core/Graph/CakeGraph.cs +++ b/src/Cake.Core/Graph/CakeGraph.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace Cake.Core.Graph { @@ -112,6 +114,29 @@ public IEnumerable Traverse(string target) return result; } + /// + /// Traverses the graph asynchrounus leading to the specified target. + /// + /// The target to traverse to. + /// Action which will be called on each task. + /// A task to wait for. + public async Task TraverseAsync(string target, Func executeTask) + { + if (!Exist(target)) + { + return; + } + + if (HasCircularReferences(target)) + { + throw new CakeException("Graph contains circular references."); + } + + var cancellationTokenSource = new CancellationTokenSource(); + var visitedNodes = new Dictionary(); + await TraverseAsync(target, executeTask, cancellationTokenSource, visitedNodes); + } + private void Traverse(string node, ICollection result, ISet visited = null) { visited = visited ?? new HashSet(StringComparer.OrdinalIgnoreCase); @@ -130,5 +155,59 @@ private void Traverse(string node, ICollection result, ISet visi throw new CakeException("Graph contains circular references."); } } + + private async Task TraverseAsync(string node, Func executeTask, + CancellationTokenSource cancellationTokenSource, IDictionary visitedNodes) + { + if (visitedNodes.ContainsKey(node)) + { + await visitedNodes[node]; + return; + } + + var token = cancellationTokenSource.Token; + var dependentTasks = _edges + .Where(x => x.End.Equals(node, StringComparison.OrdinalIgnoreCase)) + .Select(x => + { + var task = TraverseAsync(x.Start, executeTask, cancellationTokenSource, visitedNodes); + visitedNodes[x.Start] = task; + + if (task.IsFaulted) + { + throw task.Exception; + } + + return task; + }) + .ToArray(); + + if (dependentTasks.Any()) + { + TaskCompletionSource tcs = new TaskCompletionSource(); + token.Register(() => tcs.TrySetCanceled(), false); + await Task.WhenAny(Task.WhenAll(dependentTasks), tcs.Task); + } + + await executeTask(node, cancellationTokenSource); + } + + private bool HasCircularReferences(string node, Stack visited = null) + { + visited = visited ?? new Stack(); + + if (visited.Contains(node)) + { + return true; + } + + visited.Push(node); + var hasCircularReference = _edges + .Where(x => x.End.Equals(node, StringComparison.OrdinalIgnoreCase)) + .Any(x => HasCircularReferences(x.Start, visited)); + visited.Pop(); + + return hasCircularReference; + } } } \ No newline at end of file diff --git a/src/Cake.Frosting/Internal/Commands/DefaultCommand.cs b/src/Cake.Frosting/Internal/Commands/DefaultCommand.cs index 6d0df33735..672aac40fb 100644 --- a/src/Cake.Frosting/Internal/Commands/DefaultCommand.cs +++ b/src/Cake.Frosting/Internal/Commands/DefaultCommand.cs @@ -68,6 +68,11 @@ public override int Execute(CommandContext context, DefaultCommandSettings setti runner.Settings.UseExclusiveTarget(); } + if (settings.Parallel) + { + runner.Settings.RunInParallel(); + } + runner.Run(settings.Target); } catch (Exception ex) diff --git a/src/Cake.Frosting/Internal/Commands/DefaultCommandSettings.cs b/src/Cake.Frosting/Internal/Commands/DefaultCommandSettings.cs index ea0d6b7f64..fb00988e8f 100644 --- a/src/Cake.Frosting/Internal/Commands/DefaultCommandSettings.cs +++ b/src/Cake.Frosting/Internal/Commands/DefaultCommandSettings.cs @@ -51,5 +51,9 @@ internal sealed class DefaultCommandSettings : CommandSettings [CommandOption("--info")] [Description("Displays additional information about Cake.")] public bool Info { get; set; } + + [CommandOption("--parallel|-p")] + [Description("Enables the support for parallel tasks.")] + public bool Parallel { get; set; } } } diff --git a/src/Cake.Tests/Unit/ProgramTests.cs b/src/Cake.Tests/Unit/ProgramTests.cs index 34ceaf2dc1..69fe5f8c1c 100644 --- a/src/Cake.Tests/Unit/ProgramTests.cs +++ b/src/Cake.Tests/Unit/ProgramTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Threading.Tasks; using Autofac; using Cake.Cli; @@ -32,6 +31,7 @@ public async Task Should_Use_Default_Parameters_By_Default() settings.BuildHostKind == BuildHostKind.Build && settings.Debug == false && settings.Exclusive == false && + settings.Parallel == false && settings.Script.FullPath == "build.cake" && settings.Verbosity == Verbosity.Normal && settings.NoBootstrapping == false)); diff --git a/src/Cake/Commands/DefaultCommand.cs b/src/Cake/Commands/DefaultCommand.cs index eafcfa72cf..ce6db277ab 100644 --- a/src/Cake/Commands/DefaultCommand.cs +++ b/src/Cake/Commands/DefaultCommand.cs @@ -75,6 +75,7 @@ public override int Execute(CommandContext context, DefaultCommandSettings setti Script = settings.Script, Verbosity = settings.Verbosity, Exclusive = settings.Exclusive, + Parallel = settings.Parallel, Debug = settings.Debug, NoBootstrapping = settings.SkipBootstrap, }); diff --git a/src/Cake/Commands/DefaultCommandSettings.cs b/src/Cake/Commands/DefaultCommandSettings.cs index 5e7fce5691..770394271c 100644 --- a/src/Cake/Commands/DefaultCommandSettings.cs +++ b/src/Cake/Commands/DefaultCommandSettings.cs @@ -63,5 +63,9 @@ public sealed class DefaultCommandSettings : CommandSettings [CommandOption("--" + Infrastructure.Constants.Cache.InvalidateScriptCache)] [Description("Forces the script to be recompiled if caching is enabled.")] public bool Recompile { get; set; } + + [CommandOption("--parallel|-p")] + [Description("Enables the support for parallel tasks.")] + public bool Parallel { get; set; } } } diff --git a/src/Cake/Features/Building/BuildFeature.cs b/src/Cake/Features/Building/BuildFeature.cs index 1dd9b82fc6..4ec08e14a3 100644 --- a/src/Cake/Features/Building/BuildFeature.cs +++ b/src/Cake/Features/Building/BuildFeature.cs @@ -102,6 +102,11 @@ void LoadModules(ICakeContainerRegistrar registrar) host.Settings.UseExclusiveTarget(); } + if (settings.Parallel) + { + host.Settings.RunInParallel(); + } + // Debug? if (settings.Debug) { diff --git a/src/Cake/Features/Building/BuildFeatureSettings.cs b/src/Cake/Features/Building/BuildFeatureSettings.cs index 47f35a5a86..41af9127c7 100644 --- a/src/Cake/Features/Building/BuildFeatureSettings.cs +++ b/src/Cake/Features/Building/BuildFeatureSettings.cs @@ -15,6 +15,7 @@ public sealed class BuildFeatureSettings : IScriptHostSettings public Verbosity Verbosity { get; set; } public bool Debug { get; set; } public bool Exclusive { get; set; } + public bool Parallel { get; set; } public bool NoBootstrapping { get; set; } public BuildFeatureSettings(BuildHostKind buildHostKind)