Skip to content
Merged
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
102 changes: 102 additions & 0 deletions BuildMonitor.Test/BuildPriorityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.BuildMonitor.Test;

using ktsu.Semantics.Strings;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Covers how a build's polling priority reads its runs. It used to read only the most recently started
/// run across every branch, so a long run on main still going after a later PR run had finished dropped
/// the build to Low, and main then failing never reached the recent-failure tier
/// (ktsu-dev/BuildMonitor#392).
/// </summary>
[TestClass]
public sealed class BuildPriorityTests
{
private static readonly DateTimeOffset T0 = new(2026, 11, 3, 6, 0, 0, TimeSpan.Zero);

[TestCleanup]
public void Cleanup() => Clock.Reset();

private static Run AddRun(Build build, string id, string branch, DateTimeOffset started, RunStatus status, DateTimeOffset lastUpdated)
{
Run run = build.CreateRun(id.As<RunName>());
run.Branch = branch.As<BranchName>();
run.Started = started;
run.Status = status;
run.LastUpdated = lastUpdated;
build.Runs[run.Id] = run;
build.UpdateFromRun(run);
return run;
}

[TestMethod]
public void ARunStillGoingOnAnotherBranchKeepsTheBuildHigh()
{
// Arrange
Clock.UtcNowOverride = () => T0.AddMinutes(13);
Build build = new();
AddRun(build, "1", "main", T0, RunStatus.Running, T0.AddMinutes(12));
AddRun(build, "2", "feature", T0.AddMinutes(10), RunStatus.Success, T0.AddMinutes(12));

// Act
RequestPriority priority = new BuildSync { Build = build }.Priority;

// Assert
Assert.AreEqual(RequestPriority.High, priority, "main is still running, so the build should be polled at the running rate");
}

[TestMethod]
public void AFailureOnAnotherBranchIsARecentFailure()
{
// Arrange
Clock.UtcNowOverride = () => T0.AddMinutes(21);
Build build = new();
Run main = AddRun(build, "1", "main", T0, RunStatus.Running, T0.AddMinutes(12));
AddRun(build, "2", "feature", T0.AddMinutes(10), RunStatus.Success, T0.AddMinutes(12));

// Act
main.Status = RunStatus.Failure;
main.LastUpdated = T0.AddMinutes(20);
build.UpdateFromRun(main);
RequestPriority priority = new BuildSync { Build = build }.Priority;

// Assert
Assert.AreEqual(RequestPriority.Medium, priority, "main's newest run failed a minute ago");
}

[TestMethod]
public void AnOlderFailureSupersededOnItsBranchIsNotRecent()
{
// Arrange
Clock.UtcNowOverride = () => T0.AddMinutes(30);
Build build = new();
AddRun(build, "1", "main", T0, RunStatus.Failure, T0.AddMinutes(5));
AddRun(build, "2", "main", T0.AddMinutes(10), RunStatus.Success, T0.AddMinutes(15));
AddRun(build, "3", "feature", T0.AddMinutes(20), RunStatus.Success, T0.AddMinutes(25));

// Act
RequestPriority priority = new BuildSync { Build = build }.Priority;

// Assert
Assert.AreEqual(RequestPriority.Low, priority, "main's failure was fixed by a later run on main");
}

[TestMethod]
public void AFailureOverAnHourOldIsLow()
{
// Arrange
Clock.UtcNowOverride = () => T0.AddHours(3);
Build build = new();
AddRun(build, "1", "main", T0, RunStatus.Failure, T0.AddMinutes(20));
AddRun(build, "2", "feature", T0.AddMinutes(10), RunStatus.Success, T0.AddMinutes(12));

// Act
RequestPriority priority = new BuildSync { Build = build }.Priority;

// Assert
Assert.AreEqual(RequestPriority.Low, priority);
}
}
25 changes: 25 additions & 0 deletions BuildMonitor/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ internal sealed class Build
public RunStatus LastStatus { get; set; }
internal bool IsOngoing => !Runs.IsEmpty && LastStatus is RunStatus.Pending or RunStatus.Running;

/// <summary>
/// Gets a value indicating whether any of the build's runs, on any branch, is still pending or running.
/// <see cref="IsOngoing"/> reads only the most recently started run, so an earlier run on another
/// branch that is still going was missed once a later one finished (ktsu-dev/BuildMonitor#392).
/// </summary>
internal bool HasOngoingRun => IsOngoing || Runs.Values.Any(run => run.IsOngoing);

/// <summary>
/// Returns whether the newest run on any branch failed and was last updated after <paramref name="since"/>.
/// </summary>
/// <param name="since">The earliest update time that counts as recent.</param>
/// <returns><see langword="true"/> if some branch's newest run is a recent failure.</returns>
internal bool HasRecentBranchFailure(DateTimeOffset since)
{
if (LastStatus == RunStatus.Failure && LastUpdated > since)
{
return true;
}

return Runs.Values
.GroupBy(run => run.Branch)
.Select(branch => branch.MaxBy(run => run.Started)!)
.Any(newest => newest.Status == RunStatus.Failure && newest.LastUpdated > since);
}

/// <summary>
/// Calculates the estimated duration for this build using sophisticated statistical methods.
/// Uses all successful runs across all branches.
Expand Down
9 changes: 4 additions & 5 deletions BuildMonitor/BuildSync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,14 @@ internal RequestPriority Priority
{
get
{
// Running builds are highest priority
if (Build.IsOngoing)
// Running builds are highest priority, whichever branch the running run is on
if (Build.HasOngoingRun)
{
return RequestPriority.High;
}

// Recent failures are medium priority (within last hour)
if (Build.LastStatus == RunStatus.Failure &&
Build.LastUpdated > Clock.UtcNow.AddHours(-1))
// Recent failures are medium priority (within last hour), on any branch
if (Build.HasRecentBranchFailure(Clock.UtcNow.AddHours(-1)))
{
return RequestPriority.Medium;
}
Expand Down
Loading