perf: engine-wide performance optimizations#5520
Conversation
…ing, and reporting Fix critical hash degradation, eliminate O(n²) algorithms, reduce allocations, and improve thread safety across 14 files in TUnit.Core and TUnit.Engine. Key changes: - Fix ConstraintKeysCollection.GetHashCode() returning constant 1, restoring O(1) dictionary lookups (was O(n) due to hash collisions) - Replace O(n²) topological sort in test discovery with Kahn's algorithm O(V+E) - Replace ConcurrentBag with ConcurrentQueue for better enumeration performance - Replace 6-pass LINQ categorization in GitHubReporter with single-pass loop - Fix thread safety in GitHubReporter/JUnitReporter (List → ConcurrentQueue) - Replace GroupBy + Count() with single-pass counter loops in orchestrators - Cache reflection results (ExplicitAttribute, ConstructorInfo, PropertyBag) - Guard trace log string interpolation behind IsTraceEnabled checks - Add TestContext.RemoveById() to prevent memory leak in long-running hosts - Remove redundant EnsureTestSessionHooksExecutedAsync call - Add fast-path in XML sanitization to skip StringBuilder when not needed - Make factory lambdas static to avoid delegate allocations
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 1 high |
| CodeStyle | 2 minor |
🟢 Metrics 21 complexity
Metric Results Complexity 21
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review: perf/engine-wide-performance-optimizations
Overall this is a solid, well-motivated PR with real improvements across the board. The O(n²) → O(V+E) topological sort, ConcurrentBag → ConcurrentQueue, single-pass categorization, and logging guard fixes are all clearly correct. A few areas warrant attention.
1. [Medium] ConstraintKeysCollection.GetHashCode() violates the hash/equals contract
The new XOR hash is inconsistent with the intersection-based Equals semantics. Consider:
var a = new ConstraintKeysCollection(["keyA", "keyB"]); // hash = H("keyA") ^ H("keyB")
var b = new ConstraintKeysCollection(["keyB", "keyC"]); // hash = H("keyB") ^ H("keyC")
a.Equals(b); // true — they share "keyB"
a.GetHashCode() == b.GetHashCode(); // almost certainly falseThe contract "equal objects must have equal hashes" is broken for multi-key collections. The original return 1 was degenerate but technically consistent with intersection-equality.
Why this matters: Any code using ConstraintKeysCollectionComparer as a Dictionary or HashSet comparer will silently misbehave (miss lookups) for multi-key collections. The static comparer property is public API surface inviting exactly that use case.
Better approach: Since the equality semantics is "these two tests share a constraint key and therefore cannot run in parallel together", the right fix is probably to change Equals to mean set equality (not intersection), which would allow a well-defined consistent hash. If intersection semantics must be preserved, the hash must be a function solely of the intersection — which can't be computed without knowing the other object, making consistent hashing impossible. In that case, return 0 (not return 1) with a comment explaining the intentional degenerate hash is the only correct option.
2. [Low-Medium] SingleOrDefault<TestNodeStateProperty>() can throw
GitHubReporter.cs and JUnitReporter.cs (implicitly)
var state = kvp.Value.TestNode.Properties.SingleOrDefault<TestNodeStateProperty>();
switch (state)
{
case FailedTestNodeStateProperty or ErrorTestNodeStateProperty:
...SingleOrDefault<T>() throws InvalidOperationException if the sequence contains more than one element of type T. The original six separate .Any() checks were more defensive and would silently categorise a test with multiple state properties (however unusual).
Better approach: Use FirstOrDefault<T>() (or .OfType<TestNodeStateProperty>().FirstOrDefault()) to be safe:
var state = kvp.Value.TestNode.Properties.OfType<TestNodeStateProperty>().FirstOrDefault();This preserves the performance intent while avoiding a potential crash if the platform contract is looser than assumed.
3. [Low] _latestUpdates has a minor ordering race
In ConsumeAsync:
_updates.GetOrAdd(uid, static _ => []).Enqueue(testNodeUpdateMessage);
_latestUpdates[uid] = testNodeUpdateMessage;These two operations are not atomic. If two updates arrive concurrently for the same test, the value in _latestUpdates might end up as the message from the first-completing thread, not the last one enqueued. In practice, test state transitions are monotonic (InProgress → terminal state), so this should rarely matter. Worth a code comment noting the intentional trade-off so a future reader doesn't "fix" it with a lock.
(Note: the original List<T>.Add() was a genuine data corruption race — this PR correctly moves to ConcurrentQueue, so this is a minor improvement over before.)
4. [Informational] Kahn's algorithm — cycle-break fallback is correct but undocumented
The cycle-break at the end:
if (yieldedCount < dependentTests.Count)
{
foreach (var test in dependentTests)
{
if (!yieldedTests.Contains(test.TestId)) { yield return test; }
}
}This correctly handles cycles, but note: tests in a cycle will be yielded without respecting their dependency order, which means [DependsOn] tests in a cycle may start before their declared dependencies finish. This matches the old code's fallback behaviour, but a log warning here would make the breakage visible:
if (yieldedCount < dependentTests.Count)
{
_logger.LogWarning("Circular dependency detected — some [DependsOn] tests will run out of order");
...
}5. [Positive] Cleanly removed redundant EnsureTestSessionHooksExecutedAsync call
Confirmed TestExecutor.ExecuteAsync (line 80) already calls this unconditionally before any test work. The removal from TestCoordinator is safe and correct.
6. [Positive] TestScheduler — removing the redundant OrderBy on SortedDictionary
SortedDictionary<int, ...>.Values already enumerates in ascending key order. The removal of OrderBy(t => t.Key) is correct.
7. [Positive] TestContext.RemoveById placement is correct for retries
The call is at the very end of ExecuteTestInternalAsync, after all retry handling completes. No retry iteration will observe a missing context.
- Revert ConstraintKeysCollection.GetHashCode() to constant (0) — intersection-based Equals means two "equal" collections can have different key sets, making a content-based hash violate the hash/equals contract - Replace SingleOrDefault<TestNodeStateProperty>() with OfType<>().FirstOrDefault() in GitHubReporter to avoid throwing if multiple state properties exist - Add comments on _latestUpdates explaining the intentional non-atomic ordering trade-off - Improve cycle-break comment in Kahn's algorithm noting CircularDependencyDetector handles the error reporting
There was a problem hiding this comment.
Follow-up Review: perf/engine-wide-performance-optimizations
Previous review issues have been largely addressed by the fix commit (5cddaccc). Here's an assessment of each, plus new findings.
Previous Review — Resolution Status
- ✅ Issue #1 (Hash/equals contract): Correctly reverted
GetHashCode()to a constant (return 0) with an explanatory comment. The comment accurately explains why a content-based hash would violate the contract given intersection-basedEqualssemantics. - ✅ Issue #2 (SingleOrDefault can throw): Replaced with
OfType<TestNodeStateProperty>().FirstOrDefault()— correct fix. - ✅ Issue #3 (_latestUpdates ordering race): Acknowledged with a code comment explaining the intentional trade-off. Acceptable.
- ✅ Issue #4 (Cycle-break fallback): Comment updated to note that
CircularDependencyDetectorhandles error reporting. Good.
New Issue: _updates field is dead code in both reporters [Medium]
GitHubReporter.cs and JUnitReporter.cs
After the refactor, _updates (the full history dictionary) is still populated in ConsumeAsync but is never read in AfterRunAsync. All reporting now goes through _latestUpdates. This means the entire update history for every test accumulates in memory for the lifetime of the reporter, serving no purpose.
// ConsumeAsync — writes to both:
_updates.GetOrAdd(uid, static _ => []).Enqueue(testNodeUpdateMessage); // ← never read
_latestUpdates[uid] = testNodeUpdateMessage; // ← actually used
// AfterRunAsync — reads only _latestUpdates:
foreach (var kvp in _latestUpdates) { last[kvp.Key] = kvp.Value; }Why this matters: In a large test suite with thousands of tests, each emitting multiple lifecycle updates (InProgress, then a terminal state), _updates grows unbounded and is garbage-collected only when the reporter is torn down. This partially undoes the memory improvement this PR intended to deliver.
Better approach: Remove _updates entirely from both reporters. If full update history is ever needed in future, it can be re-introduced intentionally. Alternatively, if there's a reason to keep _updates (e.g., planned future use), add a comment explaining why.
New Issue: PR description inconsistency [Low / Documentation]
The PR description still says:
Fix critical hash degradation:
ConstraintKeysCollection.GetHashCode()was returning constant1... Now uses XOR-based order-independent hash
But the actual code (after the fix commit) still uses a constant: return 0. The XOR approach was correctly reverted. The PR description should be updated to reflect the actual change: the constant hash is now documented with an explanatory comment, and return 1 → return 0 is a cosmetic/clarity change only.
Minor: _treeNodeFilterConstructor indexer assumption [Low]
MetadataFilterMatcher.cs
private static readonly ConstructorInfo _treeNodeFilterConstructor =
typeof(TreeNodeFilter).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)[0];Using [0] assumes the first non-public instance constructor is the string-parameter one. If TreeNodeFilter ever gains an additional private constructor (e.g., a parameterless one for serialization), this silently picks the wrong constructor. The caching improvement is worthwhile; the selection is fragile.
More robust approach:
private static readonly ConstructorInfo _treeNodeFilterConstructor =
typeof(TreeNodeFilter).GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
[typeof(string)])!;This is explicit about which constructor signature is expected and will throw immediately at startup if the constructor is removed, rather than silently misbehaving at runtime.
Positives
FormatParallelLimiterInfohelper cleanly eliminates 4 copy-pasted snippets. Good refactor.IsTraceEnabledguards throughoutTestGroupingService— avoids string interpolation on every test when trace logging is disabled. This is the correct pattern.- Kahn's algorithm in
TestDiscoveryServiceis correctly implemented. The in-degree and reverse-dependency map construction handles pre-yielded independent tests correctly (their dependencies start at in-degree 0 and are seeded intoreadyQueueimmediately). Assemblyas dictionary key inEventReceiverOrchestratoris correct —Assemblyobjects use reference equality within an AppDomain, so the same loaded assembly always maps to the same key, avoiding repeatedGetName().FullNamestring allocations.SortedDictionaryOrderByremoval inTestScheduleris correct.SortedDictionary<TKey, TValue>.Valuesalready enumerates in ascending key order.
Summary
The fix commit successfully addresses all four issues from the first review. The main new finding worth acting on is the dead _updates field in both reporters — it accumulates unbounded history that is never consumed. Removing it would be a genuine improvement that aligns with the PR's stated goal of reducing memory usage. The other two findings (description inconsistency and constructor indexer) are low-priority.
- Remove dead _updates field from JUnitReporter (only _latestUpdates is needed) - Fix second SingleOrDefault in GitHubReporter flaky detection loop - Use _latestUpdates.IsEmpty for empty check in both reporters - Use typed constructor lookup in MetadataFilterMatcher instead of fragile [0] indexer
There was a problem hiding this comment.
Follow-up Review (3rd Pass)
All issues raised in the two prior reviews have been addressed. Here is the current status and any remaining observations.
Previous Review — Final Resolution Status
- ✅ Hash/equals contract —
GetHashCode()returns constant0with an explanatory comment. PR description updated to match (no more XOR mention). - ✅
SingleOrDefaultcan throw — Both occurrences inGitHubReporterreplaced with.OfType<TestNodeStateProperty>().FirstOrDefault(). - ✅
_latestUpdatesordering race — Documented with a code comment explaining the intentional trade-off. - ✅ Cycle-break fallback — Comment updated noting that
CircularDependencyDetectorhandles the error path. - ✅ Dead
_updatesin JUnitReporter — Removed in the fix commit.JUnitReporternow cleanly uses only_latestUpdates. - ✅
_treeNodeFilterConstructorfragile indexer — Fixed. Now uses explicit typedGetConstructor(BindingFlags..., [typeof(string)])that will fail loudly at startup if the API changes.
Clarification: GitHubReporter _updates is NOT dead code
The second review incorrectly flagged _updates in GitHubReporter as dead. It is still actively read in AfterRunAsync's flaky-detection loop:
foreach (var update in kvp.Value) // iterating _updates full history
{
var state = update.TestNode.Properties.OfType<TestNodeStateProperty>().FirstOrDefault();
if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty)
finalStateCount++;
}This is the correct design: _latestUpdates gives O(1) final state for bulk pass/fail categorisation; _updates provides the full per-test update history for counting retry/state transitions (flakiness detection). The fix commit rightly removed _updates from JUnitReporter (which never needed the history) while leaving it intact in GitHubReporter (which does).
One Minor Observation
_strippedFilterCache (MetadataFilterMatcher) is an unbounded static cache. In practice, filter strings are set at test run startup and bounded, so this will never grow uncontrollably. Worth keeping in mind only if TUnit ever moves to a long-running hot-reload server model where filters could accumulate. No action needed now — just noting for future awareness.
Overall Assessment
This PR is in excellent shape. The changes are well-motivated, correctly implemented, and all review concerns have been resolved. The combination of Kahn's O(V+E) topological sort, elimination of per-call reflection, logging guards, ConcurrentBag → ConcurrentQueue, and single-pass categorisation represents a meaningful, production-quality performance improvement.
Approved.
…es, lightweight flaky counter - Replace full TestNodeUpdateMessage history queue with int counter for flaky detection in GitHubReporter - Add braces to nested foreach in TestScheduler for style consistency - Remove WHAT-narrating comments (code is self-explanatory)
There was a problem hiding this comment.
Follow-up Review (4th Pass)
This review covers the fourth commit (de9cbf07e9), which landed after the 3rd-pass approval.
Changes in the 4th commit
GitHubReporter — replace full update queue with int counter
The previous fix introduced a ConcurrentQueue<TestNodeUpdateMessage> to store full update history (replacing the original List<T> data-corruption race). This commit replaces it with a ConcurrentDictionary<string, int> _terminalStateCounts that only counts terminal state transitions. This is correct:
- Flaky detection only needed a count, not the full message history. The full message for display was already in
_latestUpdates. - Memory reduction is significant: instead of N
TestNodeUpdateMessageobjects per flaky test, only oneintper test. - Thread-safety is equivalent:
AddOrUpdatewith a static lambda is already the pattern used elsewhere in this file.
The minor non-atomic gap between _terminalStateCounts.AddOrUpdate and _latestUpdates[uid] = remains, but this was already acknowledged in the 2nd review and documented there — the monotonic nature of test state transitions makes a stale read harmless at report time.
TestScheduler — added braces to nested foreach
Correctness-only; no logic change.
Removed narrating comments
Appropriate cleanup. The removed comments described what the code does rather than why, which adds noise without value.
Final Status of All Prior Issues
- ✅ Hash/equals contract — constant
return 0with explanatory comment - ✅
SingleOrDefault→OfType<>.FirstOrDefault()— safe - ✅
_latestUpdatesordering race — documented intentional trade-off - ✅ Cycle-break fallback —
CircularDependencyDetectornoted in comment - ✅ Dead
_updatesin JUnitReporter — removed - ✅ Dead
_updatesin GitHubReporter — replaced with lightweight int counter - ✅
_treeNodeFilterConstructorindexer — explicit typed lookup
Overall Assessment
The PR is clean and complete. All four commits tell a coherent story: initial optimization → two rounds of fixes → lightweight cleanup. The final state delivers the stated goals (Kahn's sort, ConcurrentBag → ConcurrentQueue, single-pass categorization, allocation-free fast paths) without introducing new issues.
Approved.
Summary
GetHashCode()was returning constant1with no documentation. Replaced LINQIntersect().Any()inEqualswith allocation-free nested loop (optimized for typical 1-2 constraint keys). Hash remains a constant (0) with a comment explaining why — intersection-based equality makes content-based hashing impossible.TestDiscoveryService.DiscoverTestsFullyStreamingAsyncwith Kahn's algorithm using in-degree counting and reverse-dependency map — O(V+E).TestDiscoveryService._cachedTestsfor O(n) enumeration without snapshot copying.GitHubReporter.AfterRunAsync— replaces 6 separate LINQ passes over all test results with one switch-based loop.List<T>replaced withConcurrentQueue<T>, with a parallel_latestUpdatesdictionary for O(1) last-element access. Removed dead_updatesfield from JUnitReporter.EventReceiverOrchestratorandTestLifecycleCoordinator— replaced with single-pass counter initialization loops.ExplicitAttributeclass lookup,ConstructorInfoforTreeNodeFilter(with explicit typed lookup), staticPropertyBag, and regex-stripped filter strings inMetadataFilterMatcher.TestGroupingService— wrap all trace log sites withIsTraceEnabledchecks to avoid string interpolation when tracing is off. ExtractFormatParallelLimiterInfohelper to deduplicate 4 identical snippets.TestContext.RemoveById()and call it after test completion to clean up the static_testContextsByIddictionary in long-running test host scenarios.EnsureTestSessionHooksExecutedAsyncwas called inTestCoordinatorbut already runs unconditionally inTestExecutor.ExecuteAsync.JUnitXmlWriter.SanitizeForXml; return original string unchanged (zero allocation) when clean.staticin reporters to avoid delegate allocations..Where().ToList()with manual loop.TestScheduler— remove redundant.OrderBy()on already-sortedSortedDictionary.ConstraintKeysCollectionEqualityComparernow delegates to instanceEquals/GetHashCodeinstead of duplicating the logic.EventReceiverOrchestratorinstead ofAssembly.GetName().FullNamestring.Test plan
[DependsOn]to verify topological sort correctness[NotInParallel]constraint keys to verify hash-based grouping