Skip to content

Parallelize ReadIssues task processing for improved performance with multiple providers #1151

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Cake.Issues.Testing/FakeFailingIssueProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Cake.Issues.Testing;

using System.Collections.Generic;
using Cake.Core.Diagnostics;

/// <summary>
/// Implementation of a <see cref="BaseIssueProvider"/> that fails during initialization for use in test cases.
/// </summary>
/// <param name="log">The Cake log instance.</param>
public class FakeFailingIssueProvider(ICakeLog log) : BaseIssueProvider(log)
{
/// <inheritdoc/>
public override string ProviderName => "Fake Failing Issue Provider";

/// <inheritdoc/>
public override bool Initialize(IReadIssuesSettings settings) => false;

/// <inheritdoc/>
protected override IEnumerable<IIssue> InternalReadIssues() => [];
}
40 changes: 40 additions & 0 deletions src/Cake.Issues.Testing/FakeSlowIssueProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace Cake.Issues.Testing;

using System.Collections.Generic;
using System.Threading;
using Cake.Core.Diagnostics;

/// <summary>
/// Implementation of a <see cref="BaseIssueProvider"/> that simulates slow processing for performance testing.
/// </summary>
public class FakeSlowIssueProvider : BaseIssueProvider
{
private readonly List<IIssue> issues = [];
private readonly int delayMs;

/// <summary>
/// Initializes a new instance of the <see cref="FakeSlowIssueProvider"/> class.
/// </summary>
/// <param name="log">The Cake log instance.</param>
/// <param name="issues">Issues which should be returned by the issue provider.</param>
/// <param name="delayMs">Simulated processing delay in milliseconds.</param>
public FakeSlowIssueProvider(ICakeLog log, IEnumerable<IIssue> issues, int delayMs)
: base(log)
{
issues.NotNull();

this.issues.AddRange(issues);
this.delayMs = delayMs;
}

/// <inheritdoc/>
public override string ProviderName => "Fake Slow Issue Provider";

/// <inheritdoc/>
protected override IEnumerable<IIssue> InternalReadIssues()
{
// Simulate slow processing
Thread.Sleep(this.delayMs);
return this.issues;
}
}
131 changes: 129 additions & 2 deletions src/Cake.Issues.Tests/IssueReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public void Should_Initialize_Issue_Provider()
_ = fixture.ReadIssues();

// Then
fixture.IssueProviders.ShouldAllBe(x => x.Settings == fixture.Settings);
fixture.IssueProviders.OfType<FakeIssueProvider>().ShouldAllBe(x => x.Settings == fixture.Settings);
}

[Fact]
Expand Down Expand Up @@ -142,7 +142,7 @@ public void Should_Initialize_All_Issue_Provider()
_ = fixture.ReadIssues();

// Then
fixture.IssueProviders.ShouldAllBe(x => x.Settings == fixture.Settings);
fixture.IssueProviders.OfType<FakeIssueProvider>().ShouldAllBe(x => x.Settings == fixture.Settings);
}

[Fact]
Expand Down Expand Up @@ -349,5 +349,132 @@ public void Should_Set_FileLink_Property()
issue2.FileLink.ToString()
.ShouldBe($"{repoUrl}/blob/{branch}/{filePath2.Replace(@"\", "/")}#L{line2}");
}

[Fact]
public void Should_Read_Issues_From_Multiple_Providers_Concurrently()
{
// Given
const int providerCount = 10;
const int issuesPerProvider = 100;
var fixture = new IssuesFixture();
fixture.IssueProviders.Clear();

// Create multiple providers with different issues
for (var i = 0; i < providerCount; i++)
{
var providerIssues = new List<IIssue>();
for (var j = 0; j < issuesPerProvider; j++)
{
var issue = IssueBuilder
.NewIssue($"Issue{i}-{j}", $"ProviderType{i}", $"ProviderName{i}")
.InFile($@"src\Provider{i}\File{j}.cs", j + 1)
.OfRule($"Rule{j}")
.WithPriority(IssuePriority.Warning)
.Create();
providerIssues.Add(issue);
}
fixture.IssueProviders.Add(new FakeIssueProvider(fixture.Log, providerIssues));
}

// When
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var issues = fixture.ReadIssues().ToList();
stopwatch.Stop();

// Then
issues.Count.ShouldBe(providerCount * issuesPerProvider);

// Verify all providers contributed issues
for (var i = 0; i < providerCount; i++)
{
var providerIssues = issues.Where(x => x.ProviderType == $"ProviderType{i}").ToList();
providerIssues.Count.ShouldBe(issuesPerProvider);
}

// Verify all Run properties are set
issues.ShouldAllBe(x => x.Run == fixture.Settings.Run);

// Log timing for performance verification
System.Console.WriteLine($"Reading {issues.Count} issues from {providerCount} providers took {stopwatch.ElapsedMilliseconds}ms");
}

[Fact]
public void Should_Handle_Provider_Initialization_Failures_Concurrently()
{
// Given
var fixture = new IssuesFixture();
fixture.IssueProviders.Clear();

// Add mix of successful and failing providers
fixture.IssueProviders.Add(new FakeIssueProvider(fixture.Log, [
IssueBuilder.NewIssue("Success1", "ProviderType1", "ProviderName1")
.InFile(@"src\File1.cs", 1)
.OfRule("Rule1")
.WithPriority(IssuePriority.Warning)
.Create()
]));

// Create a failing provider by passing null settings later
var failingProvider = new FakeFailingIssueProvider(fixture.Log);
fixture.IssueProviders.Add(failingProvider);
fixture.IssueProviders.Add(new FakeIssueProvider(fixture.Log, [
IssueBuilder.NewIssue("Success2", "ProviderType2", "ProviderName2")
.InFile(@"src\File2.cs", 2)
.OfRule("Rule2")
.WithPriority(IssuePriority.Warning)
.Create()
]));

// When
var issues = fixture.ReadIssues().ToList();

// Then
// Should get issues from successful providers only
issues.Count.ShouldBe(2);
issues.ShouldContain(x => x.MessageText == "Success1");
issues.ShouldContain(x => x.MessageText == "Success2");
}

[Fact]
public void Should_Demonstrate_Parallel_Processing_Benefits_With_Simulated_Delays()
{
// Given - Create providers that simulate processing delays
const int providerCount = 5;
const int delayPerProviderMs = 50; // Simulate 50ms delay per provider
var fixture = new IssuesFixture();
fixture.IssueProviders.Clear();

for (var i = 0; i < providerCount; i++)
{
var issue = IssueBuilder
.NewIssue($"SlowIssue{i}", $"SlowProviderType{i}", $"SlowProviderName{i}")
.InFile($@"src\SlowFile{i}.cs", i + 1)
.OfRule($"SlowRule{i}")
.WithPriority(IssuePriority.Warning)
.Create();
fixture.IssueProviders.Add(new FakeSlowIssueProvider(fixture.Log, [issue], delayPerProviderMs));
}

// When
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var issues = fixture.ReadIssues().ToList();
stopwatch.Stop();

// Then
issues.Count.ShouldBe(providerCount);

// With parallel processing, total time should be significantly less than
// sum of all delays (providerCount * delayPerProviderMs)
var expectedSequentialTime = providerCount * delayPerProviderMs;
var actualTime = stopwatch.ElapsedMilliseconds;

Console.WriteLine($"Sequential time would be ~{expectedSequentialTime}ms, parallel time was {actualTime}ms");
actualTime.ShouldBeLessThan(expectedSequentialTime, "Parallel reading of issue providers is slower than sequential processing would be");

// This assertion may be flaky in CI environments, so we'll use a generous threshold
// Should be much faster than 40% of sequential time
//var maxExpectedParallelTime = expectedSequentialTime * 0.4;
//Convert.ToDouble(actualTime).ShouldBeLessThan(maxExpectedParallelTime, "Parallel reading of issue providers is more than 40% of time it took for sequential processing");
}
}
}
4 changes: 2 additions & 2 deletions src/Cake.Issues.Tests/IssuesFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ public class IssuesFixture
public IssuesFixture()
{
this.Log = new FakeLog { Verbosity = Verbosity.Normal };
this.IssueProviders = [new(this.Log)];
this.IssueProviders = [new FakeIssueProvider(this.Log)];
this.Settings =
new ReadIssuesSettings(
new Core.IO.DirectoryPath(@"c:\Source\Cake.Issues"));
}

public FakeLog Log { get; init; }

public IList<FakeIssueProvider> IssueProviders { get; init; }
public IList<IIssueProvider> IssueProviders { get; init; }

public ReadIssuesSettings Settings { get; init; }

Expand Down
77 changes: 49 additions & 28 deletions src/Cake.Issues/IssuesReader.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
namespace Cake.Issues;

using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Cake.Core.Diagnostics;

/// <summary>
Expand Down Expand Up @@ -41,40 +43,59 @@ public IssuesReader(
/// <returns>List of issues.</returns>
public IEnumerable<IIssue> ReadIssues()
{
// Initialize issue providers and read issues.
var issues = new List<IIssue>();
foreach (var issueProvider in this.issueProviders)
var stopwatch = Stopwatch.StartNew();

var results = new List<IIssue>[this.issueProviders.Count];

// Process providers in parallel
_ = Parallel.For(0, this.issueProviders.Count, i =>
{
var providerName = issueProvider.GetType().Name;
this.log.Verbose("Initialize issue provider {0}...", providerName);
if (issueProvider.Initialize(this.settings))
{
this.log.Verbose("Reading issues from {0}...", providerName);
var currentIssues = issueProvider.ReadIssues().ToList();
results[i] = this.ReadIssuesFromProvider(this.issueProviders[i]);
});

this.log.Verbose(
"Found {0} issues using issue provider {1}...",
currentIssues.Count,
providerName);
stopwatch.Stop();

currentIssues.ForEach(x =>
{
x.Run = this.settings.Run;
var issuesList = results.SelectMany(r => r).ToList();
this.log.Verbose(
"Reading {0} issues from {1} providers took {2} ms",
issuesList.Count,
this.issueProviders.Count,
stopwatch.ElapsedMilliseconds);

if (this.settings.FileLinkSettings != null)
{
x.FileLink = this.settings.FileLinkSettings.GetFileLink(x);
}
});
return issuesList;
}

private List<IIssue> ReadIssuesFromProvider(IIssueProvider issueProvider)
{
var providerName = issueProvider.GetType().Name;
this.log.Verbose("Initialize issue provider {0}...", providerName);

issues.AddRange(currentIssues);
}
else
if (issueProvider.Initialize(this.settings))
{
this.log.Verbose("Reading issues from {0}...", providerName);
var currentIssues = issueProvider.ReadIssues().ToList();

this.log.Verbose(
"Found {0} issues using issue provider {1}...",
currentIssues.Count,
providerName);

// Post-process issues - this is thread-safe as each provider gets its own issues
currentIssues.ForEach(x =>
{
this.log.Warning("Error initializing issue provider {0}.", providerName);
}
}
x.Run = this.settings.Run;

return issues;
if (this.settings.FileLinkSettings != null)
{
x.FileLink = this.settings.FileLinkSettings.GetFileLink(x);
}
});
return currentIssues;
}
else
{
this.log.Warning("Error initializing issue provider {0}.", providerName);
return [];
}
}
}
Loading