Skip to content
This repository has been archived by the owner on Nov 20, 2023. It is now read-only.

Commit

Permalink
Build queue for --watch (#1189)
Browse files Browse the repository at this point in the history
* Central build queue, w/ build from solution

* Fix some warnings

* Need to use -target and not -targets

Also needed to update msbuild version past 16.10 because of a bug that
meant that the default targets of projects couldn't be built.

* Remove unnecessary function

* Handle failures building solution better

* Styling updates.

* Scaffold explicit start/stop of builds.

* More styling updates.

* Cleanup working directory.

* Make build result more consistent.

* Tweak logging.

* Update formatting.

* Update more formatting.

* Add locks for critical areas.

* Update schema and reference doc.

* Fix formatting.

* Updates per PR feedback.

* More updates per PR feedback.

Co-authored-by: James Lloyd <james.lloyd@onlineseminar.nl>
Co-authored-by: phoff <phoff@microsoft.com>
  • Loading branch information
3 people authored Feb 9, 2022
1 parent 3ea9e9e commit f677a15
Show file tree
Hide file tree
Showing 11 changed files with 329 additions and 11 deletions.
4 changes: 4 additions & 0 deletions docs/reference/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ Specifies the list of ingresses.

Specifies the list of services. Applications must have at least one service.

#### `solution` (string)

Indicates the solution file (.sln) or filter (.slnf) to use when building project-based services in watch mode. If omitted, those services will be built individually. Specifying the solution [filter] can help reduce repeated builds of shared libraries when in watch mode.

## Service

`Service` elements appear in a list within the `services` root property.
Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.Tye.Core/ApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ public ApplicationBuilder(FileInfo source, string name, ContainerEngine containe
public List<IngressBuilder> Ingress { get; } = new List<IngressBuilder>();

public string? Network { get; set; }
public string? BuildSolution { get; internal set; }
}
}
3 changes: 2 additions & 1 deletion src/Microsoft.Tye.Core/ApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public static async Task<ApplicationBuilder> CreateAsync(OutputContext output, F

var root = new ApplicationBuilder(source, rootConfig.Name!, new ContainerEngine(rootConfig.ContainerEngineType), rootConfig.DashboardPort)
{
Namespace = rootConfig.Namespace
Namespace = rootConfig.Namespace,
BuildSolution = rootConfig.BuildSolution,
};

queue.Enqueue((rootConfig, new HashSet<string>()));
Expand Down
2 changes: 2 additions & 0 deletions src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public class ConfigApplication

public int? DashboardPort { get; set; }

public string? BuildSolution { get; set; }

public string? Namespace { get; set; }

public string? Registry { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.Tye.Core/MsBuild/SolutionFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ internal string SolutionFileDirectory

#region Methods

internal bool ProjectShouldBuild(string projectFile)
public bool ProjectShouldBuild(string projectFile)
{
return _solutionFilter?.Contains(FileUtilities.FixFilePath(projectFile)) != false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public static void HandleConfigApplication(YamlMappingNode yamlMappingNode, Conf
case "name":
app.Name = YamlParser.GetScalarValue(key, child.Value);
break;
case "solution":
app.BuildSolution = YamlParser.GetScalarValue(key, child.Value);
break;
case "namespace":
app.Namespace = YamlParser.GetScalarValue(key, child.Value);
break;
Expand Down
293 changes: 293 additions & 0 deletions src/Microsoft.Tye.Hosting/BuildWatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Build.Construction;
using Microsoft.Extensions.Logging;

namespace Microsoft.Tye.Hosting
{
internal sealed class BuildWatcher : IAsyncDisposable
{
private CancellationTokenSource? _cancellationTokenSource;
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
private readonly ILogger _logger;
private Task? _processor;
private Channel<BuildRequest>? _queue;

public BuildWatcher(ILogger logger)
{
_logger = logger;
}

public Task StartAsync(string? solutionPath, string workingDirectory)
{
return WithLockAsync(
async () =>
{
await ResetAsync();
_queue = Channel.CreateUnbounded<BuildRequest>();
_cancellationTokenSource = new CancellationTokenSource();
_processor = Task.Run(() => ProcessTaskQueueAsync(_logger, _queue.Reader, solutionPath, workingDirectory, _cancellationTokenSource.Token));
});
}

public Task StopAsync()
{
return WithLockAsync(ResetAsync);
}

public async Task<int> BuildProjectFileAsync(string projectFilePath)
{
var request = new BuildRequest(projectFilePath);

await WithLockAsync(
async () =>
{
if (_queue == null)
{
throw new InvalidOperationException("The worker is not running.");
}
await _queue.Writer.WriteAsync(request);
});

return await request.Task;
}

#region IAsyncDisposable Members

public async ValueTask DisposeAsync()
{
await WithLockAsync(ResetAsync);

_lock.Dispose();
}

#endregion

private async Task WithLockAsync(Func<Task> action)
{
await _lock.WaitAsync();

try
{
await action();
}
finally
{
_lock.Release();
}
}

private async Task ResetAsync()
{
if (_queue != null)
{
_queue.Writer.TryComplete();
_queue = null;
}

if (_cancellationTokenSource != null)
{
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
_cancellationTokenSource = null;
}

if (_processor != null)
{
await _processor;

_processor = null;
}
}

private static string GetProjectName(SolutionFile solution, string projectFile)
{
foreach (var project in solution.ProjectsInOrder)
{
if (project.AbsolutePath == projectFile)
{
return project.ProjectName;
}
}

throw new InvalidOperationException($"Could not find project in solution: {projectFile}");
}

private static async Task ProcessTaskQueueAsync(
ILogger logger,
ChannelReader<BuildRequest> requestReader,
string? solutionPath,
string workingDirectory,
CancellationToken cancellationToken)
{
logger.LogInformation("Build Watcher: Watching for builds...");

try
{
while (await requestReader.WaitToReadAsync(cancellationToken))
{
var delay = TimeSpan.FromMilliseconds(250);

logger.LogInformation("Build Watcher: Builds requested; waiting {DelayInMs}ms for more...", delay.TotalMilliseconds);

await Task.Delay(delay);

logger.LogInformation("Build Watcher: Getting requests...");

var requests = new List<BuildRequest>();

while (requestReader.TryRead(out var request))
{
requests.Add(request);
}

logger.LogInformation("Build Watcher: Processing {Count} requests...", requests.Count);

var solution = (solutionPath != null) ? SolutionFile.Parse(solutionPath) : null;

var solutionBatch = new Dictionary<string, List<BuildRequest>>(); // store the list of promises
var projectBatch = new Dictionary<string, List<BuildRequest>>();

foreach (var request in requests)
{
if (solution?.ProjectShouldBuild(request.ProjectFilePath) == true)
{
if (!solutionBatch.ContainsKey(request.ProjectFilePath))
{
solutionBatch.Add(request.ProjectFilePath, new List<BuildRequest>());
}

solutionBatch[request.ProjectFilePath].Add(request);
}
else
{
// this will also prevent us building multiple times if a project is used by multiple services
if (!projectBatch.ContainsKey(request.ProjectFilePath))
{
projectBatch.Add(request.ProjectFilePath, new List<BuildRequest>());
}

projectBatch[request.ProjectFilePath].Add(request);
}
}

async Task WithRequestCompletion(IEnumerable<BuildRequest> requests, Func<Task<int>> buildFunc)
{
try
{
int exitCode = await buildFunc();

foreach (var request in requests)
{
request.Complete(exitCode);
}
}
catch (Exception ex)
{
foreach (var request in requests)
{
request.Complete(ex);
}
}
}

var tasks = new List<Task>();

if (solutionBatch.Any())
{
var targets = String.Join(",", solutionBatch.Keys.Select(key => GetProjectName(solution!, key)));

tasks.Add(
WithRequestCompletion(
solutionBatch.Values.SelectMany(x => x),
async () =>
{
logger.LogInformation("Build Watcher: Building {Targets} of solution {SolutionPath}...", targets, solutionPath);
var buildResult = await ProcessUtil.RunAsync("dotnet", $"msbuild {solutionPath} -target:{targets}", throwOnError: false, workingDirectory: workingDirectory, cancellationToken: cancellationToken);
if (buildResult.ExitCode != 0)
{
logger.LogInformation("Build Watcher: Solution build failed with exit code {ExitCode}: \r\n" + buildResult.StandardOutput, buildResult.ExitCode);
}
return buildResult.ExitCode;
}));
}

foreach (var project in projectBatch)
{
tasks.Add(
WithRequestCompletion(
project.Value,
async () =>
{
logger.LogInformation("Build Watcher: Building project {ProjectPath}...", project.Key);
var buildResult = await ProcessUtil.RunAsync("dotnet", $"build \"{project.Key}\" /nologo", throwOnError: false, workingDirectory: workingDirectory, cancellationToken: cancellationToken);
if (buildResult.ExitCode != 0)
{
logger.LogInformation("Build Watcher: Project build failed with exit code {ExitCode}: \r\n" + buildResult.StandardOutput, buildResult.ExitCode);
}
return buildResult.ExitCode;
}));
}

logger.LogInformation("Build Watcher: Waiting for builds to complete...");

// NOTE: WithRequestCompletion() will trap exceptions so build errors should not bubble up from WhenAll().

await Task.WhenAll(tasks);

logger.LogInformation("Build Watcher: Done with requests; waiting for more...");
}
}
catch (OperationCanceledException)
{
// NO-OP: Trap exception due to cancellation.
}
catch (Exception ex)
{
logger.LogError(ex, "Build Watcher: Error while processing builds.");
}

logger.LogInformation("Build Watcher: Done watching.");
}

private class BuildRequest
{
private readonly TaskCompletionSource<int> _result = new TaskCompletionSource<int>();

public BuildRequest(string projectFilePath)
{
ProjectFilePath = projectFilePath;
}

public string ProjectFilePath { get; }

public Task<int> Task => _result.Task;

public void Complete(int exitCode)
{
_result.TrySetResult(exitCode);
}

public void Complete(Exception ex)
{
_result.TrySetException(ex);
}
}
}
}
1 change: 1 addition & 0 deletions src/Microsoft.Tye.Hosting/Model/Application.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public Application(string name, FileInfo source, int? dashboardPort, Dictionary<
public Dictionary<object, object> Items { get; } = new Dictionary<object, object>();

public string? Network { get; set; }
public string? BuildSolution { get; set; }

public void PopulateEnvironment(Service service, Action<string, string> set, string defaultHost = "localhost")
{
Expand Down
Loading

0 comments on commit f677a15

Please sign in to comment.