From f616728d1509219beb5ff7686a36842e4d63d432 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 7 Aug 2025 02:10:42 +0100 Subject: [PATCH] feat: Add parallel initialization for injected properties at the same level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properties with data sources at the same level are now initialized in parallel, significantly improving startup time for complex dependency graphs. Changes: - Modified PropertyInjectionService to process properties in parallel using Task.WhenAll - Added parallel processing for source-generated and reflection-based property injection - Added parallel processing for test method arguments - Added comprehensive tests demonstrating performance improvements (60-70% faster) Example use case: Web factory with Redis, SQL, and MessageBus containers that don't depend on each other now initialize simultaneously instead of sequentially. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- TUnit.Core/PropertyInjectionService.cs | 39 ++- .../ParallelPropertyInjectionTests.cs | 227 ++++++++++++++++++ 2 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 TUnit.TestProject/ParallelPropertyInjectionTests.cs diff --git a/TUnit.Core/PropertyInjectionService.cs b/TUnit.Core/PropertyInjectionService.cs index 46d0208a29e..965fbf148f2 100644 --- a/TUnit.Core/PropertyInjectionService.cs +++ b/TUnit.Core/PropertyInjectionService.cs @@ -17,6 +17,7 @@ public sealed class PropertyInjectionService /// /// Injects properties with data sources into argument objects just before test execution. /// This ensures properties are only initialized when the test is about to run. + /// Arguments are processed in parallel for better performance. /// public static async Task InjectPropertiesIntoArgumentsAsync(object?[] arguments, Dictionary objectBag, MethodMetadata methodMetadata, TestContextEvents events) { @@ -25,13 +26,13 @@ public static async Task InjectPropertiesIntoArgumentsAsync(object?[] arguments, return; } - foreach (var argument in arguments) - { - if (argument != null && ShouldInjectProperties(argument)) - { - await InjectPropertiesIntoObjectAsync(argument, objectBag, methodMetadata, events); - } - } + // Process arguments in parallel + var argumentTasks = arguments + .Where(argument => argument != null && ShouldInjectProperties(argument)) + .Select(argument => InjectPropertiesIntoObjectAsync(argument!, objectBag, methodMetadata, events)) + .ToArray(); + + await Task.WhenAll(argumentTasks); } /// @@ -109,6 +110,7 @@ await _injectionTasks.GetOrAdd(instance, async _ => /// /// Injects properties using source-generated metadata (AOT-safe mode). + /// Properties at the same level are initialized in parallel for better performance. /// private static async Task InjectPropertiesUsingSourceGenerationAsync(object instance, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events) { @@ -119,15 +121,18 @@ private static async Task InjectPropertiesUsingSourceGenerationAsync(object inst { var propertyMetadata = propertySource.GetPropertyMetadata(); - foreach (var metadata in propertyMetadata) - { - await ProcessPropertyMetadata(instance, metadata, objectBag, methodMetadata, events, TestContext.Current); - } + // Process all properties at the same level in parallel + var propertyTasks = propertyMetadata.Select(metadata => + ProcessPropertyMetadata(instance, metadata, objectBag, methodMetadata, events, TestContext.Current) + ).ToArray(); + + await Task.WhenAll(propertyTasks); } } /// /// Injects properties using runtime reflection (full feature mode). + /// Properties at the same level are initialized in parallel for better performance. /// [UnconditionalSuppressMessage("Trimming", "IL2075:\'this\' argument does not satisfy \'DynamicallyAccessedMembersAttribute\' in call to target method. The return value of the source method does not have matching annotations.")] private static async Task InjectPropertiesUsingReflectionAsync(object instance, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events) @@ -136,16 +141,26 @@ private static async Task InjectPropertiesUsingReflectionAsync(object instance, var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static) .Where(p => p.CanWrite || p.SetMethod?.IsPublic == false); // Include init-only properties + // Collect all property-datasource pairs + var propertyDataSourcePairs = new List<(PropertyInfo property, IDataSourceAttribute dataSource)>(); + foreach (var property in properties) { foreach (var attr in property.GetCustomAttributes()) { if (attr is IDataSourceAttribute dataSourceAttr) { - await ProcessReflectionPropertyDataSource(instance, property, dataSourceAttr, objectBag, methodMetadata, events, TestContext.Current); + propertyDataSourcePairs.Add((property, dataSourceAttr)); } } } + + // Process all properties in parallel + var propertyTasks = propertyDataSourcePairs.Select(pair => + ProcessReflectionPropertyDataSource(instance, pair.property, pair.dataSource, objectBag, methodMetadata, events, TestContext.Current) + ).ToArray(); + + await Task.WhenAll(propertyTasks); } /// diff --git a/TUnit.TestProject/ParallelPropertyInjectionTests.cs b/TUnit.TestProject/ParallelPropertyInjectionTests.cs new file mode 100644 index 00000000000..3a643069e8c --- /dev/null +++ b/TUnit.TestProject/ParallelPropertyInjectionTests.cs @@ -0,0 +1,227 @@ +using System.Diagnostics; +using TUnit.TestProject.Attributes; +using TUnit.Core.Interfaces; + +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Pass)] +public class ParallelPropertyInjectionTests +{ + // Simulated container classes that take time to initialize + public class RedisContainer : IAsyncInitializer + { + public DateTime InitializedAt { get; private set; } + public TimeSpan InitializationDuration { get; private set; } + + public async Task InitializeAsync() + { + var startTime = DateTime.UtcNow; + // Simulate Redis container startup + await Task.Delay(100); + InitializedAt = DateTime.UtcNow; + InitializationDuration = InitializedAt - startTime; + Console.WriteLine($"Redis initialized at {InitializedAt:HH:mm:ss.fff}, took {InitializationDuration.TotalMilliseconds}ms"); + } + } + + public class SqlContainer : IAsyncInitializer + { + public DateTime InitializedAt { get; private set; } + public TimeSpan InitializationDuration { get; private set; } + + public async Task InitializeAsync() + { + var startTime = DateTime.UtcNow; + // Simulate SQL container startup + await Task.Delay(150); + InitializedAt = DateTime.UtcNow; + InitializationDuration = InitializedAt - startTime; + Console.WriteLine($"SQL initialized at {InitializedAt:HH:mm:ss.fff}, took {InitializationDuration.TotalMilliseconds}ms"); + } + } + + public class MessageBusContainer : IAsyncInitializer + { + public DateTime InitializedAt { get; private set; } + public TimeSpan InitializationDuration { get; private set; } + + public async Task InitializeAsync() + { + var startTime = DateTime.UtcNow; + // Simulate message bus container startup + await Task.Delay(120); + InitializedAt = DateTime.UtcNow; + InitializationDuration = InitializedAt - startTime; + Console.WriteLine($"MessageBus initialized at {InitializedAt:HH:mm:ss.fff}, took {InitializationDuration.TotalMilliseconds}ms"); + } + } + + // Web application factory with multiple injected containers + public class WebApplicationFactory + { + [MethodDataSource(nameof(GetRedisContainer))] + public required RedisContainer Redis { get; set; } + + [MethodDataSource(nameof(GetSqlContainer))] + public required SqlContainer Sql { get; set; } + + [MethodDataSource(nameof(GetMessageBusContainer))] + public required MessageBusContainer MessageBus { get; set; } + + public static Func GetRedisContainer() + { + return () => new RedisContainer(); + } + + public static Func GetSqlContainer() + { + return () => new SqlContainer(); + } + + public static Func GetMessageBusContainer() + { + return () => new MessageBusContainer(); + } + } + + [Test] + [ClassDataSource] + public async Task Test_ParallelPropertyInitialization_ShouldInitializeContainersInParallel(WebApplicationFactory factory) + { + // Verify all containers are initialized + await Assert.That(factory.Redis).IsNotNull(); + await Assert.That(factory.Sql).IsNotNull(); + await Assert.That(factory.MessageBus).IsNotNull(); + + // Check that they were all initialized (IAsyncInitializer.InitializeAsync was called) + await Assert.That(factory.Redis.InitializedAt).IsNotEqualTo(default(DateTime)); + await Assert.That(factory.Sql.InitializedAt).IsNotEqualTo(default(DateTime)); + await Assert.That(factory.MessageBus.InitializedAt).IsNotEqualTo(default(DateTime)); + + // Calculate the total time if they were sequential vs parallel + var totalSequentialTime = factory.Redis.InitializationDuration + + factory.Sql.InitializationDuration + + factory.MessageBus.InitializationDuration; + + // Find the actual total time (should be close to the max individual time if parallel) + var earliestStart = new[] { + factory.Redis.InitializedAt - factory.Redis.InitializationDuration, + factory.Sql.InitializedAt - factory.Sql.InitializationDuration, + factory.MessageBus.InitializedAt - factory.MessageBus.InitializationDuration + }.Min(); + + var latestEnd = new[] { + factory.Redis.InitializedAt, + factory.Sql.InitializedAt, + factory.MessageBus.InitializedAt + }.Max(); + + var actualTotalTime = latestEnd - earliestStart; + + Console.WriteLine($"Sequential time would be: {totalSequentialTime.TotalMilliseconds}ms"); + Console.WriteLine($"Actual total time: {actualTotalTime.TotalMilliseconds}ms"); + Console.WriteLine($"Time saved by parallel initialization: {(totalSequentialTime - actualTotalTime).TotalMilliseconds}ms"); + + // Verify parallel execution: actual time should be significantly less than sequential time + // Allow some margin for thread scheduling overhead + await Assert.That(actualTotalTime.TotalMilliseconds).IsLessThan(totalSequentialTime.TotalMilliseconds * 0.8); + } + + // Test with nested properties that also benefit from parallel initialization + public class ComplexWebFactory + { + [MethodDataSource(nameof(GetDatabaseCluster))] + public required DatabaseCluster Database { get; set; } + + [MethodDataSource(nameof(GetCacheCluster))] + public required CacheCluster Cache { get; set; } + + public static Func GetDatabaseCluster() + { + return () => new DatabaseCluster { PrimarySql = null!, SecondarySql = null! }; + } + + public static Func GetCacheCluster() + { + return () => new CacheCluster { PrimaryRedis = null!, SecondaryRedis = null! }; + } + } + + public class DatabaseCluster + { + [MethodDataSource(nameof(GetPrimarySql))] + public required SqlContainer PrimarySql { get; set; } + + [MethodDataSource(nameof(GetSecondarySql))] + public required SqlContainer SecondarySql { get; set; } + + public static Func GetPrimarySql() + { + return () => new SqlContainer(); + } + + public static Func GetSecondarySql() + { + return () => new SqlContainer(); + } + } + + public class CacheCluster + { + [MethodDataSource(nameof(GetPrimaryRedis))] + public required RedisContainer PrimaryRedis { get; set; } + + [MethodDataSource(nameof(GetSecondaryRedis))] + public required RedisContainer SecondaryRedis { get; set; } + + public static Func GetPrimaryRedis() + { + return () => new RedisContainer(); + } + + public static Func GetSecondaryRedis() + { + return () => new RedisContainer(); + } + } + + [Test] + [ClassDataSource] + public async Task Test_NestedParallelPropertyInitialization_ShouldInitializeAllLevelsInParallel(ComplexWebFactory factory) + { + // Verify all nested containers are initialized + await Assert.That(factory.Database).IsNotNull(); + await Assert.That(factory.Database.PrimarySql).IsNotNull(); + await Assert.That(factory.Database.SecondarySql).IsNotNull(); + await Assert.That(factory.Cache).IsNotNull(); + await Assert.That(factory.Cache.PrimaryRedis).IsNotNull(); + await Assert.That(factory.Cache.SecondaryRedis).IsNotNull(); + + // Check initialization times + var allContainers = new List<(string name, DateTime initializedAt, TimeSpan duration)> + { + ("Database.PrimarySql", factory.Database.PrimarySql.InitializedAt, factory.Database.PrimarySql.InitializationDuration), + ("Database.SecondarySql", factory.Database.SecondarySql.InitializedAt, factory.Database.SecondarySql.InitializationDuration), + ("Cache.PrimaryRedis", factory.Cache.PrimaryRedis.InitializedAt, factory.Cache.PrimaryRedis.InitializationDuration), + ("Cache.SecondaryRedis", factory.Cache.SecondaryRedis.InitializedAt, factory.Cache.SecondaryRedis.InitializationDuration) + }; + + foreach (var container in allContainers) + { + Console.WriteLine($"{container.name} initialized at {container.initializedAt:HH:mm:ss.fff}, took {container.duration.TotalMilliseconds}ms"); + } + + // Calculate potential sequential vs actual time + var totalSequentialTime = allContainers.Sum(c => c.duration.TotalMilliseconds); + var earliestStart = allContainers.Min(c => c.initializedAt - c.duration); + var latestEnd = allContainers.Max(c => c.initializedAt); + var actualTotalTime = (latestEnd - earliestStart).TotalMilliseconds; + + Console.WriteLine($"Sequential time would be: {totalSequentialTime}ms"); + Console.WriteLine($"Actual total time: {actualTotalTime}ms"); + Console.WriteLine($"Time saved by parallel initialization: {totalSequentialTime - actualTotalTime}ms"); + + // Properties at the same level should be initialized in parallel + await Assert.That(actualTotalTime).IsLessThan(totalSequentialTime * 0.8); + } +} \ No newline at end of file