Skip to content

Commit 27c98ef

Browse files
[INT-324] [C#] Support Parallel Test Sessions (#243)
Co-authored-by: Sebastian Alex <[email protected]>
1 parent 994abd7 commit 27c98ef

File tree

5 files changed

+311
-33
lines changed

5 files changed

+311
-33
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System;
2+
using System.Linq;
3+
using System.Net;
4+
using System.Net.Http;
5+
using System.Net.Http.Headers;
6+
using System.Text;
7+
using System.Threading.Tasks;
8+
using Newtonsoft.Json;
9+
using NUnit.Framework;
10+
using RichardSzalay.MockHttp;
11+
using SauceLabs.Visual.GraphQL;
12+
using SauceLabs.Visual.Models;
13+
using System.Collections.Generic;
14+
using Moq;
15+
using GraphQL;
16+
17+
namespace SauceLabs.Visual.Tests;
18+
19+
public class BuildFactoryParallelTest
20+
{
21+
MockHttpMessageHandler MockedHandler;
22+
private const string _username = "dummy-username";
23+
private const string _accessKey = "dummy-key";
24+
private int _buildCounter = 0;
25+
26+
[SetUp]
27+
public void Setup()
28+
{
29+
MockedHandler = new MockHttpMessageHandler();
30+
_buildCounter = 0;
31+
32+
var createHandler = () =>
33+
{
34+
_buildCounter++;
35+
36+
var id = RandomString(32);
37+
var content = new VisualBuild(id, $"http://dummy/test/{id}", BuildMode.Running);
38+
var resp = new HttpResponseMessage(HttpStatusCode.OK);
39+
resp.Content = new ReadOnlyMemoryContent(ToResult(content));
40+
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
41+
return Task.FromResult(resp);
42+
};
43+
44+
var base64EncodedAuthenticationString =
45+
Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_username}:{_accessKey}"));
46+
var regions = new[] { Region.Staging, Region.UsEast4, Region.EuCentral1, Region.UsWest1 };
47+
foreach (var r in regions)
48+
{
49+
MockedHandler
50+
.When(r.Value.ToString())
51+
.WithHeaders($"Authorization: Basic {base64EncodedAuthenticationString}")
52+
.WithPartialContent($"\"operationName\":\"{CreateBuildMutation.OperationName}\"")
53+
.Respond(createHandler);
54+
MockedHandler
55+
.When(r.Value.ToString())
56+
.WithHeaders($"Authorization: Basic {base64EncodedAuthenticationString}")
57+
.WithPartialContent($"\"operationName\":\"{FinishBuildMutation.OperationName}\"")
58+
.Respond(createHandler);
59+
}
60+
MockedHandler.Fallback.Throw(new InvalidOperationException("No matching mock handler"));
61+
}
62+
63+
[TearDown]
64+
public async Task Cleanup()
65+
{
66+
await BuildFactory.CloseBuilds();
67+
MockedHandler.Dispose();
68+
}
69+
70+
private string RandomString(int length)
71+
{
72+
const string chars = "abcdef0123456789";
73+
return new string(Enumerable.Repeat(chars, length).Select(s => s[Random.Shared.Next(s.Length)]).ToArray());
74+
}
75+
76+
private byte[] ToResult(object o)
77+
{
78+
var nestedContent = new
79+
{
80+
Data = new
81+
{
82+
Result = o
83+
},
84+
};
85+
return Encoding.ASCII.GetBytes(JsonConvert.SerializeObject(nestedContent));
86+
}
87+
88+
[Test]
89+
public async Task BuildFactory_CreateOnlyOneBuildWhenCalledInParallel()
90+
{
91+
var api = new VisualApi(Region.Staging, _username, _accessKey, MockedHandler.ToHttpClient());
92+
var options = new CreateBuildOptions { Name = "ParallelBuildTest" };
93+
const int parallelCalls = 6;
94+
95+
var tasks = Enumerable.Range(0, parallelCalls)
96+
.Select(_ => BuildFactory.Get(api, options))
97+
.ToArray();
98+
99+
var builds = await Task.WhenAll(tasks);
100+
101+
var firstBuild = builds[0];
102+
Assert.That(builds, Is.All.EqualTo(firstBuild), "All builds should be the same instance");
103+
Assert.AreEqual(1, _buildCounter, "Only one build should have been created");
104+
}
105+
106+
[Test]
107+
public async Task BuildFactory_CreateDifferentBuildsWhenCalledInParallelWithDifferentNames()
108+
{
109+
var api = new VisualApi(Region.Staging, _username, _accessKey, MockedHandler.ToHttpClient());
110+
const int parallelCalls = 3;
111+
112+
var tasks = Enumerable.Range(0, parallelCalls)
113+
.Select(i => BuildFactory.Get(api, new CreateBuildOptions { Name = $"ParallelBuildTest-{i}" }))
114+
.ToArray();
115+
116+
var builds = await Task.WhenAll(tasks);
117+
118+
Assert.That(builds.Distinct().Count(), Is.EqualTo(builds.Length), "All builds should be different instances");
119+
Assert.AreEqual(parallelCalls, _buildCounter, $"Expected {parallelCalls} builds to be created");
120+
}
121+
122+
[Test]
123+
public async Task BuildFactory_CreateDifferentBuildsWhenCalledInParallelWithNamesEqualToRegionNames()
124+
{
125+
var api = new VisualApi(Region.Staging, _username, _accessKey, MockedHandler.ToHttpClient());
126+
127+
var buildOptions = new[]{
128+
new CreateBuildOptions {
129+
Name = "ParallelBuildTest-1"
130+
},
131+
new CreateBuildOptions {
132+
Name = "ParallelBuildTest-2"
133+
},
134+
new CreateBuildOptions {
135+
Name = Region.Staging.Name,
136+
},
137+
};
138+
var parallelCalls = buildOptions.Length;
139+
140+
var tasks = buildOptions
141+
.Select(opts => BuildFactory.Get(api, opts))
142+
.ToArray();
143+
144+
var builds = await Task.WhenAll(tasks);
145+
146+
Assert.That(builds.Distinct().Count(), Is.EqualTo(builds.Length), "All builds should be different instances");
147+
Assert.AreEqual(parallelCalls, _buildCounter, $"Expected {parallelCalls} builds to be created");
148+
}
149+
}

visual-dotnet/SauceLabs.Visual/BuildFactory.cs

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,120 @@
1-
using System.Collections.Generic;
1+
using System;
2+
using System.Collections.Concurrent;
23
using System.Linq;
34
using System.Threading.Tasks;
45
using SauceLabs.Visual.GraphQL;
56
using SauceLabs.Visual.Utils;
67

78
namespace SauceLabs.Visual
89
{
10+
/// <summary>
11+
/// Factory for creating and managing Visual builds.
12+
/// This class is thread-safe and supports parallel execution.
13+
/// </summary>
914
internal static class BuildFactory
1015
{
11-
private static readonly Dictionary<string, ApiBuildPair> Builds = new Dictionary<string, ApiBuildPair>();
16+
17+
private static readonly ConcurrentDictionary<BuildKey, Lazy<Task<ApiBuildPair>>> Builds =
18+
new ConcurrentDictionary<BuildKey, Lazy<Task<ApiBuildPair>>>();
1219

1320
/// <summary>
14-
/// <c>Get</c> returns the build matching with the requested region.
21+
/// <c>Get</c> returns the build matching with the requested region or name.
1522
/// If none is available, it returns a newly created build with <c>options</c>.
1623
/// It will also clone the input <c>api</c> to be able to close the build later.
24+
/// This method is thread-safe and ensures only one build is created when called concurrently with the same key.
1725
/// </summary>
1826
/// <param name="api">the api to use to create build</param>
1927
/// <param name="options">the options to use when creating the build</param>
20-
/// <returns></returns>
28+
/// <returns>A VisualBuild instance</returns>
2129
internal static async Task<VisualBuild> Get(VisualApi api, CreateBuildOptions options)
2230
{
23-
// Check if there is already a build for the current region.
24-
if (Builds.TryGetValue(api.Region.Name, out var build))
31+
BuildKey buildKey;
32+
if (!string.IsNullOrEmpty(options.Name))
33+
{
34+
buildKey = BuildKey.OfBuildName(options.Name);
35+
}
36+
else
2537
{
26-
return build.Build;
38+
buildKey = BuildKey.OfRegion(api.Region);
2739
}
2840

29-
var createdBuild = await Create(api, options);
30-
Builds[api.Region.Name] = new ApiBuildPair(api.Clone(), createdBuild);
31-
return createdBuild;
41+
var lazyBuild = Builds.GetOrAdd(buildKey, key => new Lazy<Task<ApiBuildPair>>(async () =>
42+
{
43+
var createdBuild = await Create(api, options);
44+
return new ApiBuildPair(api.Clone(), createdBuild);
45+
}));
46+
47+
try
48+
{
49+
ApiBuildPair storedBuildPair = await lazyBuild.Value;
50+
return storedBuildPair.Build;
51+
}
52+
catch
53+
{
54+
// If resolving build fails, remove the lazy task
55+
// so subsequent calls do not fail with the same result
56+
Builds.TryRemove(buildKey, out _);
57+
throw;
58+
}
3259
}
3360

3461
/// <summary>
35-
/// <c>FindRegionByBuild</c> returns the region matching the passed build.
62+
/// <c>FindBuildKey</c> returns the key (build name or region) matching the passed build.
3663
/// </summary>
3764
/// <param name="build"></param>
38-
/// <returns>the matching region name</returns>
39-
private static string? FindRegionByBuild(VisualBuild build)
65+
/// <returns>the matching build key</returns>
66+
private static BuildKey? FindBuildKey(VisualBuild build)
4067
{
41-
return Builds.Where(n => n.Value.Build == build).Select(n => n.Key).FirstOrDefault();
68+
return Builds.Where(n => n.Value.IsValueCreated && n.Value.Value.IsCompleted && n.Value.Value.Result.Build == build)
69+
.Select(n => n.Key)
70+
.FirstOrDefault();
4271
}
4372

4473
/// <summary>
45-
/// <c>Close</c> finishes and forget about <c>build</c>
74+
/// <c>Close</c> finishes and removes the specified build from the cache.
75+
/// This method is thread-safe and can be called from multiple threads.
4676
/// </summary>
4777
/// <param name="build">the build to finish</param>
4878
internal static async Task Close(VisualBuild build)
4979
{
50-
var key = FindRegionByBuild(build);
51-
if (key != null)
80+
var key = FindBuildKey(build);
81+
if (key != null && Builds.TryGetValue(key, out var lazyBuildPair) && lazyBuildPair.IsValueCreated)
5282
{
53-
await Close(key, Builds[key]);
83+
var buildPair = await lazyBuildPair.Value;
84+
await Close(key, buildPair);
5485
}
5586
}
5687

5788
/// <summary>
58-
/// <c>Close</c> finishes and forget about <c>build</c>
89+
/// <c>Close</c> finishes and removes the build from the cache.
5990
/// </summary>
60-
/// <param name="region">the build to finish</param>
91+
/// <param name="buildKey">the build key (name or region) to finish</param>
6192
/// <param name="entry">the api/build pair</param>
62-
private static async Task Close(string region, ApiBuildPair entry)
93+
private static async Task Close(BuildKey buildKey, ApiBuildPair entry)
6394
{
6495
if (!entry.Build.IsExternal)
6596
{
6697
await entry.Api.FinishBuild(entry.Build.Id);
6798
}
68-
Builds.Remove(region);
99+
100+
Builds.TryRemove(buildKey, out _);
69101
entry.Api.Dispose();
70102
}
71103

72104
/// <summary>
73-
/// <c>CloseBuilds</c> closes all build that are still open.
105+
/// <c>CloseBuilds</c> closes all builds that are still open.
106+
/// This method is thread-safe and should be called during application shutdown.
74107
/// </summary>
75108
internal static async Task CloseBuilds()
76109
{
77-
var regions = Builds.Keys;
78-
foreach (var region in regions)
110+
var buildsToClose = Builds.ToArray();
111+
foreach (var kvp in buildsToClose)
79112
{
80-
await Close(region, Builds[region]);
113+
if (kvp.Value.IsValueCreated && kvp.Value.Value.IsCompleted)
114+
{
115+
var buildPair = await kvp.Value.Value;
116+
await Close(kvp.Key, buildPair);
117+
}
81118
}
82119
}
83120

@@ -189,4 +226,4 @@ private static async Task<VisualBuild> Create(VisualApi api, CreateBuildOptions
189226
return null;
190227
}
191228
}
192-
}
229+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using RegionModel = SauceLabs.Visual.Region;
2+
3+
namespace SauceLabs.Visual
4+
{
5+
internal class BuildKey
6+
{
7+
public static Region OfRegion(RegionModel region)
8+
{
9+
return new Region(region);
10+
}
11+
12+
public static BuildName OfBuildName(string buildName)
13+
{
14+
return new BuildName(buildName);
15+
}
16+
17+
public class Region : BuildKey
18+
{
19+
public RegionModel Value { get; }
20+
21+
public Region(RegionModel region)
22+
{
23+
Value = region;
24+
}
25+
26+
public override bool Equals(object obj)
27+
{
28+
return obj switch
29+
{
30+
Region key => key.Value.Equals(Value),
31+
_ => false
32+
};
33+
}
34+
35+
public override int GetHashCode()
36+
{
37+
return Value.GetHashCode();
38+
}
39+
}
40+
41+
public class BuildName : BuildKey
42+
{
43+
public string Value { get; }
44+
45+
public BuildName(string buildName)
46+
{
47+
Value = buildName;
48+
}
49+
50+
public override bool Equals(object obj)
51+
{
52+
return obj switch
53+
{
54+
BuildName key => key.Value.Equals(Value),
55+
_ => false
56+
};
57+
}
58+
59+
public override int GetHashCode()
60+
{
61+
return Value.GetHashCode();
62+
}
63+
}
64+
}
65+
}
66+

0 commit comments

Comments
 (0)