From f394a149cc5ffecc0020bdc3b32ce1a0f65fd9a3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:41:03 +0100 Subject: [PATCH 1/2] perf(engine): remove duplicate object-graph traversal in ObjectLifecycleService (#5718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test walked its property/initializer object graph twice: once during registration (TrackableObjectGraphProvider.GetTrackableObjects) to populate TestContext.TrackedObjects, and again during execution inside InitializeObjectWithSpanAsync, which re-walked every tracked root's nested graph via InitializeNestedObjectsForExecutionAsync. Because InitializeTrackedObjectsAsync iterates TrackedObjects in descending depth order and every reachable nested object is already flattened into that sorted list at registration time, deeper dependencies are already initialized by the time a shallower object is processed. The per-object nested walk is redundant — each IAsyncInitializer will be called exactly once either way (ObjectInitializer.InitializeAsync is deduplicated via Lazy). InitializeObjectForExecutionAsync (the data-source init before the test class constructor runs) still walks the nested graph there, since TrackedObjects has not yet been iterated at that point. Estimated ~0.3-0.5% CPU across the suite; halves the inclusive time of InitializeObjectWithSpanAsync and TraverseInitializerProperties. --- TUnit.Engine/Services/ObjectLifecycleService.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index c8433af372..fd3b1d260e 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -259,7 +259,8 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel } } - // Finally initialize the test class and its nested objects. + // Finally initialize the test class itself. Its injected/data-source dependencies + // are already in TrackedObjects and were initialized in the depth-descending loop above. // The test class instance is unregistered in TraceScopeRegistry, so // GetSharedType returns null → defaults to per-test scope automatically. var classInstance = testContext.Metadata.TestDetails.ClassInstance; @@ -267,13 +268,19 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel } /// - /// Initializes an object and its nested objects, wrapped in a scope-aware OpenTelemetry span. + /// Initializes a single tracked object, wrapped in a scope-aware OpenTelemetry span. /// + /// + /// Does NOT walk the nested object graph. + /// iterates in descending-depth order, and every + /// reachable nested object was already flattened into that sorted list during + /// RegisterTestAsync (see ). Deeper + /// dependencies are therefore already initialized by the time any shallower object is + /// processed — an independent depth-first walk per object would redundantly traverse + /// the same graph (#5718). + /// private async Task InitializeObjectWithSpanAsync(object obj, TestContext testContext, CancellationToken cancellationToken) { - // First initialize nested objects depth-first - await InitializeNestedObjectsForExecutionAsync(obj, cancellationToken); - #if NET var sharedType = TraceScopeRegistry.GetSharedType(obj); var activitySource = TUnitActivitySource.GetSourceForSharedType(sharedType); From 8a8ab77ca1652d0181b6ff5ff77d343eb2a201dd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:46:01 +0100 Subject: [PATCH 2/2] Revert "perf(engine): remove duplicate object-graph traversal" (#5718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on PR #5729 reproduced locally: the optimization caused 5 test failures across reflection-mode engine tests, all for nested IAsyncInitializer objects that never got InitializeAsync called: - Test_DirectDataSource_WorksCorrectly(Data2): Data1.Value == "" - CombinedDataSource_WithNestedPropertyInjection... (True/False): address.Location.IsGeolocated == false - Test_ParallelPropertyInitialization...(WebApplicationFactory): factory.Redis.InitializedAt == default - Test_NestedParallelPropertyInitialization...(ComplexWebFactory): timing assertion (derivative of above) The removed InitializeNestedObjectsForExecutionAsync walk was not actually redundant in all cases despite TrackedObjects ostensibly containing every nested object. Some initializer paths rely on the runtime depth-first walk to initialize deeper dependencies before their owning object's InitializeAsync runs — the tracked-objects iteration alone doesn't guarantee that for data-source-provided method arguments with nested [ClassDataSource]-populated IAsyncInitializer properties. Restoring the pre-PR behavior; the ~0.3-0.5% CPU savings is not worth the correctness regression. --- TUnit.Engine/Services/ObjectLifecycleService.cs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index fd3b1d260e..c8433af372 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -259,8 +259,7 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel } } - // Finally initialize the test class itself. Its injected/data-source dependencies - // are already in TrackedObjects and were initialized in the depth-descending loop above. + // Finally initialize the test class and its nested objects. // The test class instance is unregistered in TraceScopeRegistry, so // GetSharedType returns null → defaults to per-test scope automatically. var classInstance = testContext.Metadata.TestDetails.ClassInstance; @@ -268,19 +267,13 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel } /// - /// Initializes a single tracked object, wrapped in a scope-aware OpenTelemetry span. + /// Initializes an object and its nested objects, wrapped in a scope-aware OpenTelemetry span. /// - /// - /// Does NOT walk the nested object graph. - /// iterates in descending-depth order, and every - /// reachable nested object was already flattened into that sorted list during - /// RegisterTestAsync (see ). Deeper - /// dependencies are therefore already initialized by the time any shallower object is - /// processed — an independent depth-first walk per object would redundantly traverse - /// the same graph (#5718). - /// private async Task InitializeObjectWithSpanAsync(object obj, TestContext testContext, CancellationToken cancellationToken) { + // First initialize nested objects depth-first + await InitializeNestedObjectsForExecutionAsync(obj, cancellationToken); + #if NET var sharedType = TraceScopeRegistry.GetSharedType(obj); var activitySource = TUnitActivitySource.GetSourceForSharedType(sharedType);