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
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,64 @@ public void Validate_RefsTtlAboveAdmissionTtl_Fails()
Assert.Contains("AdmissionTtl", result.FailureMessage);
}

[TestMethod]
public void Validate_RefsServeStaleForPastAdmissionTtl_Succeeds()
{
// Capped where it is used rather than refused here, so a deployment whose RefsTtl already sits
// close to AdmissionTtl keeps starting after an upgrade that introduced the default.
GitBranchStateCacheOptions options = Valid();
options.RefsTtl = TimeSpan.FromMinutes(1);
options.AdmissionTtl = TimeSpan.FromMinutes(1);

Assert.IsTrue(Validate(options).Succeeded);
}

[TestMethod]
public void Validate_NegativeRefsServeStaleFor_Fails()
{
GitBranchStateCacheOptions options = Valid();
options.RefsServeStaleFor = TimeSpan.FromSeconds(-1);

ValidateOptionsResult result = Validate(options);

Assert.IsTrue(result.Failed);
Assert.Contains("RefsServeStaleFor", result.FailureMessage);
}

[TestMethod]
public void Validate_ZeroRefsServeStaleFor_Succeeds()
{
// Zero is how a deployment makes every request past RefsTtl wait on its fetch.
GitBranchStateCacheOptions options = Valid();
options.RefsServeStaleFor = TimeSpan.Zero;

Assert.IsTrue(Validate(options).Succeeded);
}

[TestMethod]
public void Validate_NonPositiveMaxCachedMergeBases_Fails()
{
GitBranchStateCacheOptions options = Valid();
options.MaxCachedMergeBases = 0;

ValidateOptionsResult result = Validate(options);

Assert.IsTrue(result.Failed);
Assert.Contains("MaxCachedMergeBases", result.FailureMessage);
}

[TestMethod]
public void Validate_NonPositiveMaxConcurrentBranchQueries_Fails()
{
GitBranchStateCacheOptions options = Valid();
options.MaxConcurrentBranchQueries = 0;

ValidateOptionsResult result = Validate(options);

Assert.IsTrue(result.Failed);
Assert.Contains("MaxConcurrentBranchQueries", result.FailureMessage);
}

[TestMethod]
[DataRow("RefsTtl")]
[DataRow("FetchTimeout")]
Expand Down
6 changes: 6 additions & 0 deletions GitBranchStateCache.Tests/Integration/ScriptedGit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ internal sealed class ScriptedGit(MockFileSystem fileSystem) : IGitRunner
/// <summary>Gets or sets whether a diff times out rather than answering.</summary>
public bool DiffTimesOut { get; set; }

/// <summary>Gets or sets whether writing a commit-graph fails.</summary>
public bool CommitGraphFails { get; set; }

/// <summary>Gets or sets work to run before a given subcommand answers, for concurrency tests.</summary>
public Func<GitInvocation, Task>? Before { get; set; }

Expand Down Expand Up @@ -70,6 +73,9 @@ public async Task<GitResult> RunAsync(GitInvocation invocation, CancellationToke
"clone" => Clone(arguments),
"config" => Success(string.Empty),
"fetch" => Success(string.Empty),
"commit-graph" => CommitGraphFails
? new GitResult(128, string.Empty, "fatal: could not write commit-graph", TimedOut: false)
: Success(string.Empty),
"for-each-ref" => Success(ForEachRef()),
"cat-file" => CatFile(arguments),
"merge-base" => MergeBase(arguments),
Expand Down
170 changes: 170 additions & 0 deletions GitBranchStateCache.Tests/Integration/StateFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,176 @@
Assert.AreEqual(1, fixture.Git.CountOf("fetch"));
}

[TestMethod]
public async Task Post_State_WithinTheStaleWindow_AnswersWithoutWaitingForTheFetch()
{
// The first editor to ask after RefsTtl would otherwise wait on a round trip to the forge, and
// with a studio polling on a heartbeat that is a steady share of every request.
await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);

using HttpResponseMessage first = await fixture.Client.SendAsync(StateRequest(Body()));

Check warning on line 370 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-U&open=AaFjaBFXFmKwOsZo4D-U&pullRequest=99
DateTimeOffset cloned = fixture.Time.GetUtcNow();

TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
TaskCompletionSource fetched = new(TaskCreationOptions.RunContinuationsAsynchronously);
fixture.Git.Before = async invocation =>
{
if (invocation.Arguments[0] == "fetch")
{
await release.Task.ConfigureAwait(false);
fetched.TrySetResult();
}
};

fixture.Time.Advance(TimeSpan.FromSeconds(45));

// A deadline, so a request that waits on the held fetch fails here rather than hanging the run.
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10));
using HttpResponseMessage second = await fixture.Client.SendAsync(StateRequest(Body()), deadline.Token);

Assert.AreEqual(HttpStatusCode.OK, second.StatusCode);
Assert.AreEqual(cloned, (await ReadAsync(second)).GetProperty("refsAsOf").GetDateTimeOffset());

// A third request while that fetch is still running neither waits on it nor starts another.
using HttpResponseMessage third = await fixture.Client.SendAsync(StateRequest(Body()), deadline.Token);
Assert.AreEqual(HttpStatusCode.OK, third.StatusCode);

release.SetResult();
await fetched.Task.WaitAsync(TimeSpan.FromSeconds(10));

Check warning on line 398 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-V&open=AaFjaBFXFmKwOsZo4D-V&pullRequest=99
Assert.AreEqual(1, fixture.Git.CountOf("fetch"));
}

[TestMethod]
public async Task Post_State_ServingStaleRefsTurnedOff_WaitsForTheFetch()
{
await using ServiceFixture fixture = await ServiceFixture.StartAsync(new Dictionary<string, string?>
{
["GitBranchStateCache:RefsServeStaleFor"] = "00:00:00",
});
Seed(fixture.Git);

using HttpResponseMessage first = await fixture.Client.SendAsync(StateRequest(Body()));

Check warning on line 411 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-W&open=AaFjaBFXFmKwOsZo4D-W&pullRequest=99
fixture.Time.Advance(TimeSpan.FromSeconds(45));
using HttpResponseMessage second = await fixture.Client.SendAsync(StateRequest(Body()));

Check warning on line 413 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-X&open=AaFjaBFXFmKwOsZo4D-X&pullRequest=99

Assert.AreEqual(1, fixture.Git.CountOf("fetch"));
Assert.AreEqual(fixture.Time.GetUtcNow(), (await ReadAsync(second)).GetProperty("refsAsOf").GetDateTimeOffset());
}

[TestMethod]
public async Task Post_State_RepeatedRequests_FindEachMergeBaseOnce()
{
// Every heartbeat asks for the same merge bases until someone pulls or a branch moves. Asking git
// again for each one is a process per branch per request, which is most of a warm request's cost.
await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);

using HttpResponseMessage first = await fixture.Client.SendAsync(StateRequest(Body(patterns: "\"origin/*\"")));

Check warning on line 427 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-e&open=AaFjaBFXFmKwOsZo4D-e&pullRequest=99
using HttpResponseMessage second = await fixture.Client.SendAsync(StateRequest(Body(patterns: "\"origin/*\"")));

Check warning on line 428 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-f&open=AaFjaBFXFmKwOsZo4D-f&pullRequest=99

Assert.AreEqual(HttpStatusCode.OK, second.StatusCode);
Assert.AreEqual(2, fixture.Git.CountOf("merge-base"));
}

[TestMethod]
public async Task Post_State_WorksOnSeveralBranchesAtOnce()
{
await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);

int running = 0;
int peak = 0;
TaskCompletionSource bothStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
fixture.Git.Before = async invocation =>
{
if (invocation.Arguments[0] != "merge-base")
{
return;
}

int now = Interlocked.Increment(ref running);
InterlockedMax(ref peak, now);

if (now >= 2)
{
bothStarted.TrySetResult();
}

// Run one after the other, each would wait out the whole delay alone.
await Task.WhenAny(bothStarted.Task, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false);

Check warning on line 459 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-Y&open=AaFjaBFXFmKwOsZo4D-Y&pullRequest=99
Interlocked.Decrement(ref running);
};

using HttpResponseMessage response = await fixture.Client.SendAsync(StateRequest(Body(patterns: "\"origin/*\"")));

Check warning on line 463 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-Z&open=AaFjaBFXFmKwOsZo4D-Z&pullRequest=99

Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
Assert.AreEqual(2, Volatile.Read(ref peak));
}

[TestMethod]
public async Task Post_State_BranchesFinishingOutOfOrder_AreReportedInTheOrderTheyMatched()
{
await using ServiceFixture sequential = await ServiceFixture.StartAsync(new Dictionary<string, string?>
{
["GitBranchStateCache:MaxConcurrentBranchQueries"] = "1",
});
Seed(sequential.Git);
using HttpResponseMessage expected = await sequential.Client.SendAsync(StateRequest(Body(patterns: "\"origin/*\"")));

Check warning on line 477 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-b&open=AaFjaBFXFmKwOsZo4D-b&pullRequest=99
string[] expectedOrder = BranchNames(await ReadAsync(expected));

await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);

// Hold back whichever branch matched first, so it finishes last.
string firstTip = fixture.Git.Branches[expectedOrder[0]["origin/".Length..]];
fixture.Git.Before = async invocation =>
{
if (invocation.Arguments[0] == "merge-base" && invocation.Arguments[^1] == firstTip)
{
await Task.Delay(200).ConfigureAwait(false);

Check warning on line 489 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-c&open=AaFjaBFXFmKwOsZo4D-c&pullRequest=99
}
};

using HttpResponseMessage response = await fixture.Client.SendAsync(StateRequest(Body(patterns: "\"origin/*\"")));

Check warning on line 493 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-d&open=AaFjaBFXFmKwOsZo4D-d&pullRequest=99

CollectionAssert.AreEqual(expectedOrder, BranchNames(await ReadAsync(response)));

Check warning on line 495 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-a&open=AaFjaBFXFmKwOsZo4D-a&pullRequest=99
}

[TestMethod]
public async Task Post_State_ACommitGraphThatCannotBeWritten_StillAnswers()
{
// The graph only makes merge bases faster. A mirror without one answers the same questions.
await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);
fixture.Git.CommitGraphFails = true;

using HttpResponseMessage response = await fixture.Client.SendAsync(StateRequest(Body()));

Check warning on line 506 in GitBranchStateCache.Tests/Integration/StateFlowTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFjaBFXFmKwOsZo4D-g&open=AaFjaBFXFmKwOsZo4D-g&pullRequest=99

Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
Assert.AreEqual(1, fixture.Git.CountOf("commit-graph"));
}

private static string[] BranchNames(JsonElement body) =>
[.. body.GetProperty("branches").EnumerateArray().Select(branch => branch.GetProperty("name").GetString()!)];

private static void InterlockedMax(ref int target, int value)
{
int current = Volatile.Read(ref target);

while (value > current)
{
int seen = Interlocked.CompareExchange(ref target, value, current);

if (seen == current)
{
return;
}

current = seen;
}
}

[TestMethod]
public async Task Post_State_ReportsHowOldTheRefsAre()
{
Expand Down
73 changes: 73 additions & 0 deletions GitBranchStateCache.Tests/Mirrors/MirrorFetcherRealGitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,79 @@ public async Task EnsureCurrentAsync_AfterTheRefsTtl_FetchesWhatTheUpstreamHasSi
Assert.AreEqual(moved, main.Tip);
}

[TestMethod]
public async Task EnsureCurrentAsync_FirstRequest_WritesACommitGraph()
{
// Without one, every merge base is a commit walk that parses each commit out of the packs, and a
// state request asks for one per branch.
(MirrorFetcher fetcher, _, _, string directory) = Build();

await EnsureAsync(fetcher, directory);

Assert.IsTrue(HasCommitGraph(directory));
}

[TestMethod]
public async Task EnsureCurrentAsync_AfterAFetch_TheCommitGraphCoversTheNewCommits()
{
(MirrorFetcher fetcher, _, FakeTimeProvider time, string directory) = Build();

await EnsureAsync(fetcher, directory);
string before = CommitGraphFingerprint(directory);
Commit("Content/Maps/Foo.umap", "two", "second");

time.Advance(TimeSpan.FromMinutes(1));
await EnsureAsync(fetcher, directory);

// A graph that skipped the fetch would be byte for byte the one the clone wrote.
Assert.AreNotEqual(before, CommitGraphFingerprint(directory));
Git(directory, "commit-graph", "verify");
}

/// <summary>Names every commit-graph file with its length, so a rewritten graph reads differently.</summary>
private static string CommitGraphFingerprint(string directory)
{
string info = Path.Combine(directory, "objects", "info");

return string.Join(
'\n',
Directory.EnumerateFiles(info, "*graph*", SearchOption.AllDirectories)
.Order(StringComparer.Ordinal)
.Select(file => $"{Path.GetRelativePath(info, file)} {new FileInfo(file).Length}"));
}

[TestMethod]
public async Task EnsureCurrentAsync_WithinTheStaleWindow_AnswersFromDiskAndFetchesInTheBackground()
{
(MirrorFetcher fetcher, MirrorStore store, FakeTimeProvider time, string directory) = Build();

await EnsureAsync(fetcher, directory);
DateTimeOffset cloned = time.GetUtcNow();
Commit("Content/Maps/Foo.umap", "two", "second");
string moved = Git(_source, "rev-parse", "HEAD").Trim();

time.Advance(TimeSpan.FromSeconds(45));
MirrorFetchResult answered = await EnsureAsync(fetcher, directory);

// Answered with the refs on disk, and says how old they are.
Assert.AreEqual(MirrorFetchStatus.Current, answered.Status);
Assert.AreEqual(cloned, answered.RefsAsOf);

// The fetch it started lands without anything else asking.
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(30));
while (store.RefsFetchedAt(directory) == cloned)
{
await Task.Delay(50, deadline.Token);
}

BranchRef main = (await BranchesAsync(directory)).Single(branch => branch.Name == "origin/main");
Assert.AreEqual(moved, main.Tip);
}

private static bool HasCommitGraph(string directory) =>
File.Exists(Path.Combine(directory, "objects", "info", "commit-graph"))
|| Directory.Exists(Path.Combine(directory, "objects", "info", "commit-graphs"));

[TestMethod]
public async Task EnsureCurrentAsync_ABranchDeletedUpstream_IsPrunedRatherThanKept()
{
Expand Down
Loading
Loading