Skip to content

Commit 3bd2069

Browse files
thomhurstclaude
andauthored
perf: index-based constraint key scheduler for O(k) lookups (#4678)
Replace O(n) full queue drain with WaitingTestIndex that maps constraint keys to waiting tests. When keys are released, only tests waiting on those specific keys are examined. This reduces per-completion work from O(n) to O(k) where k is the number of released keys, eliminating O(n^2) total overhead for n constrained tests. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2c31ef7 commit 3bd2069

2 files changed

Lines changed: 138 additions & 34 deletions

File tree

TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs

Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Collections.Concurrent;
21
using TUnit.Core;
32
using TUnit.Core.Logging;
43
using TUnit.Engine.Logging;
@@ -41,14 +40,14 @@ public async ValueTask ExecuteTestsWithConstraintsAsync(
4140
var lockedKeys = new HashSet<string>();
4241
var lockObject = new object();
4342

44-
// Queue for tests waiting for their constraint keys to become available
45-
var waitingTests = new ConcurrentQueue<(AbstractExecutableTest Test, IReadOnlyList<string> ConstraintKeys, TaskCompletionSource<bool> StartSignal)>();
43+
// Indexed structure for tests waiting for their constraint keys to become available
44+
var waitingTestIndex = new WaitingTestIndex();
4645

4746
// Active test tasks
4847
var activeTasks = new List<Task>();
4948

5049
// Process each test
51-
foreach (var (test, constraintKeys, _) in sortedTests)
50+
foreach (var (test, constraintKeys, priority) in sortedTests)
5251
{
5352
var startSignal = new TaskCompletionSource<bool>();
5453

@@ -75,6 +74,17 @@ public async ValueTask ExecuteTestsWithConstraintsAsync(
7574
lockedKeys.Add(constraintKeys[i]);
7675
}
7776
}
77+
else
78+
{
79+
// Add to the indexed waiting structure while still under lock
80+
waitingTestIndex.Add(new WaitingTest
81+
{
82+
TestId = test.TestId,
83+
ConstraintKeys = constraintKeys,
84+
StartSignal = startSignal,
85+
Priority = priority
86+
});
87+
}
7888
}
7989

8090
if (canStart)
@@ -84,18 +94,17 @@ public async ValueTask ExecuteTestsWithConstraintsAsync(
8494
await _logger.LogDebugAsync($"Starting test {test.TestId} with constraint keys: {string.Join(", ", constraintKeys)}").ConfigureAwait(false);
8595
startSignal.SetResult(true);
8696

87-
var testTask = ExecuteTestAndReleaseKeysAsync(test, constraintKeys, lockedKeys, lockObject, waitingTests, cancellationToken);
97+
var testTask = ExecuteTestAndReleaseKeysAsync(test, constraintKeys, lockedKeys, lockObject, waitingTestIndex, cancellationToken);
8898
test.ExecutionTask = testTask;
8999
activeTasks.Add(testTask);
90100
}
91101
else
92102
{
93-
// Queue the test to wait for its keys
103+
// Test was already added to the waiting index inside the lock above
94104
if (_logger.IsDebugEnabled)
95105
await _logger.LogDebugAsync($"Queueing test {test.TestId} waiting for constraint keys: {string.Join(", ", constraintKeys)}").ConfigureAwait(false);
96-
waitingTests.Enqueue((test, constraintKeys, startSignal));
97106

98-
var testTask = WaitAndExecuteTestAsync(test, constraintKeys, startSignal, lockedKeys, lockObject, waitingTests, cancellationToken);
107+
var testTask = WaitAndExecuteTestAsync(test, constraintKeys, startSignal, lockedKeys, lockObject, waitingTestIndex, cancellationToken);
99108
test.ExecutionTask = testTask;
100109
activeTasks.Add(testTask);
101110
}
@@ -114,7 +123,7 @@ private async Task WaitAndExecuteTestAsync(
114123
TaskCompletionSource<bool> startSignal,
115124
HashSet<string> lockedKeys,
116125
object lockObject,
117-
ConcurrentQueue<(AbstractExecutableTest Test, IReadOnlyList<string> ConstraintKeys, TaskCompletionSource<bool> StartSignal)> waitingTests,
126+
WaitingTestIndex waitingTestIndex,
118127
CancellationToken cancellationToken)
119128
{
120129
// Wait for signal to start
@@ -123,7 +132,7 @@ private async Task WaitAndExecuteTestAsync(
123132
if (_logger.IsDebugEnabled)
124133
await _logger.LogDebugAsync($"Starting previously queued test {test.TestId} with constraint keys: {string.Join(", ", constraintKeys)}").ConfigureAwait(false);
125134

126-
await ExecuteTestAndReleaseKeysAsync(test, constraintKeys, lockedKeys, lockObject, waitingTests, cancellationToken).ConfigureAwait(false);
135+
await ExecuteTestAndReleaseKeysAsync(test, constraintKeys, lockedKeys, lockObject, waitingTestIndex, cancellationToken).ConfigureAwait(false);
127136
}
128137

129138
#if NET6_0_OR_GREATER
@@ -134,7 +143,7 @@ private async Task ExecuteTestAndReleaseKeysAsync(
134143
IReadOnlyList<string> constraintKeys,
135144
HashSet<string> lockedKeys,
136145
object lockObject,
137-
ConcurrentQueue<(AbstractExecutableTest Test, IReadOnlyList<string> ConstraintKeys, TaskCompletionSource<bool> StartSignal)> waitingTests,
146+
WaitingTestIndex waitingTestIndex,
138147
CancellationToken cancellationToken)
139148
{
140149
SemaphoreSlim? parallelLimiterSemaphore = null;
@@ -158,9 +167,7 @@ private async Task ExecuteTestAndReleaseKeysAsync(
158167
parallelLimiterSemaphore?.Release();
159168

160169
// Release the constraint keys and check if any waiting tests can now run
161-
// Pre-allocate lists outside the lock to minimize lock duration
162-
var testsToStart = new List<(AbstractExecutableTest Test, IReadOnlyList<string> ConstraintKeys, TaskCompletionSource<bool> StartSignal)>();
163-
var testsToRequeue = new List<(AbstractExecutableTest Test, IReadOnlyList<string> ConstraintKeys, TaskCompletionSource<bool> StartSignal)>();
170+
var testsToStart = new List<WaitingTest>();
164171

165172
lock (lockObject)
166173
{
@@ -170,16 +177,23 @@ private async Task ExecuteTestAndReleaseKeysAsync(
170177
lockedKeys.Remove(key);
171178
}
172179

173-
// Check waiting tests to see if any can now run
180+
// Only examine tests that are waiting on the keys we just released (O(k) lookup)
181+
var candidates = waitingTestIndex.GetCandidatesForReleasedKeys(constraintKeys);
174182

175-
while (waitingTests.TryDequeue(out var waitingTest))
183+
// Sort candidates by priority to respect ordering
184+
// Use a simple list + sort rather than a SortedSet to avoid per-element allocation
185+
var sortedCandidates = new List<WaitingTest>(candidates.Count);
186+
sortedCandidates.AddRange(candidates);
187+
sortedCandidates.Sort(static (a, b) => a.Priority.CompareTo(b.Priority));
188+
189+
foreach (var candidate in sortedCandidates)
176190
{
177-
// Check if all constraint keys are available for this waiting test - manual loop avoids LINQ allocation
191+
// Check if all constraint keys are available for this candidate
178192
var canStart = true;
179-
var waitingKeyCount = waitingTest.ConstraintKeys.Count;
193+
var waitingKeyCount = candidate.ConstraintKeys.Count;
180194
for (var i = 0; i < waitingKeyCount; i++)
181195
{
182-
if (lockedKeys.Contains(waitingTest.ConstraintKeys[i]))
196+
if (lockedKeys.Contains(candidate.ConstraintKeys[i]))
183197
{
184198
canStart = false;
185199
break;
@@ -191,23 +205,14 @@ private async Task ExecuteTestAndReleaseKeysAsync(
191205
// Lock the keys for this test
192206
for (var i = 0; i < waitingKeyCount; i++)
193207
{
194-
lockedKeys.Add(waitingTest.ConstraintKeys[i]);
208+
lockedKeys.Add(candidate.ConstraintKeys[i]);
195209
}
196210

197-
// Mark test to start after we exit the lock
198-
testsToStart.Add(waitingTest);
211+
// Remove from the index and mark for starting
212+
waitingTestIndex.Remove(candidate);
213+
testsToStart.Add(candidate);
199214
}
200-
else
201-
{
202-
// Still can't run, keep it in the queue
203-
testsToRequeue.Add(waitingTest);
204-
}
205-
}
206-
207-
// Re-add tests that still can't run
208-
foreach (var waitingTestItem in testsToRequeue)
209-
{
210-
waitingTests.Enqueue(waitingTestItem);
215+
// If can't start, leave it in the index for future key releases
211216
}
212217
}
213218

@@ -218,7 +223,7 @@ private async Task ExecuteTestAndReleaseKeysAsync(
218223
foreach (var testToStart in testsToStart)
219224
{
220225
if (_logger.IsDebugEnabled)
221-
await _logger.LogDebugAsync($"Unblocking waiting test {testToStart.Test.TestId} with constraint keys: {string.Join(", ", testToStart.ConstraintKeys)}").ConfigureAwait(false);
226+
await _logger.LogDebugAsync($"Unblocking waiting test {testToStart.TestId} with constraint keys: {string.Join(", ", testToStart.ConstraintKeys)}").ConfigureAwait(false);
222227
testToStart.StartSignal.SetResult(true);
223228
}
224229
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
namespace TUnit.Engine.Scheduling;
2+
3+
/// <summary>
4+
/// Represents a test that is waiting for its constraint keys to become available.
5+
/// </summary>
6+
internal sealed class WaitingTest
7+
{
8+
public required string TestId { get; init; }
9+
public required IReadOnlyList<string> ConstraintKeys { get; init; }
10+
public required TaskCompletionSource<bool> StartSignal { get; init; }
11+
public required int Priority { get; init; }
12+
}
13+
14+
/// <summary>
15+
/// Index structure that maps constraint keys to waiting tests, enabling O(k) lookup
16+
/// when keys are released instead of scanning the entire queue.
17+
/// All operations must be performed under the caller's lock.
18+
/// </summary>
19+
internal sealed class WaitingTestIndex
20+
{
21+
// Maps each constraint key to the set of tests waiting on that key
22+
private readonly Dictionary<string, HashSet<WaitingTest>> _keyToTests = new();
23+
24+
// Master set of all waiting tests (for fast membership checks and count)
25+
private readonly HashSet<WaitingTest> _allTests = new();
26+
27+
/// <summary>
28+
/// Gets the number of waiting tests currently in the index.
29+
/// </summary>
30+
public int Count => _allTests.Count;
31+
32+
/// <summary>
33+
/// Adds a waiting test to all key indexes.
34+
/// </summary>
35+
public void Add(WaitingTest waitingTest)
36+
{
37+
_allTests.Add(waitingTest);
38+
39+
var keys = waitingTest.ConstraintKeys;
40+
var keyCount = keys.Count;
41+
for (var i = 0; i < keyCount; i++)
42+
{
43+
var key = keys[i];
44+
if (!_keyToTests.TryGetValue(key, out var tests))
45+
{
46+
tests = new HashSet<WaitingTest>();
47+
_keyToTests[key] = tests;
48+
}
49+
tests.Add(waitingTest);
50+
}
51+
}
52+
53+
/// <summary>
54+
/// Removes a waiting test from all key indexes.
55+
/// </summary>
56+
public void Remove(WaitingTest waitingTest)
57+
{
58+
if (!_allTests.Remove(waitingTest))
59+
{
60+
return;
61+
}
62+
63+
var keys = waitingTest.ConstraintKeys;
64+
var keyCount = keys.Count;
65+
for (var i = 0; i < keyCount; i++)
66+
{
67+
var key = keys[i];
68+
if (_keyToTests.TryGetValue(key, out var tests))
69+
{
70+
tests.Remove(waitingTest);
71+
if (tests.Count == 0)
72+
{
73+
_keyToTests.Remove(key);
74+
}
75+
}
76+
}
77+
}
78+
79+
/// <summary>
80+
/// Returns a deduplicated set of waiting tests that are associated with any of the released keys.
81+
/// These are candidates that might be unblocked (but still need to be checked against locked keys).
82+
/// </summary>
83+
public HashSet<WaitingTest> GetCandidatesForReleasedKeys(IReadOnlyList<string> releasedKeys)
84+
{
85+
var candidates = new HashSet<WaitingTest>();
86+
87+
var keyCount = releasedKeys.Count;
88+
for (var i = 0; i < keyCount; i++)
89+
{
90+
if (_keyToTests.TryGetValue(releasedKeys[i], out var tests))
91+
{
92+
// HashSet.UnionWith handles deduplication
93+
candidates.UnionWith(tests);
94+
}
95+
}
96+
97+
return candidates;
98+
}
99+
}

0 commit comments

Comments
 (0)