diff --git a/src/Lamar.AspNetCoreTests.Integration/Lamar.AspNetCoreTests.Integration.csproj b/src/Lamar.AspNetCoreTests.Integration/Lamar.AspNetCoreTests.Integration.csproj index 0c81edd1..30bcc40e 100644 --- a/src/Lamar.AspNetCoreTests.Integration/Lamar.AspNetCoreTests.Integration.csproj +++ b/src/Lamar.AspNetCoreTests.Integration/Lamar.AspNetCoreTests.Integration.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.1;net5.0 @@ -9,9 +9,11 @@ - - - + + + + + @@ -24,6 +26,8 @@ + + diff --git a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/HealthCheckTestObjects.cs b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/HealthCheckTestObjects.cs new file mode 100644 index 00000000..7cb1b737 --- /dev/null +++ b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/HealthCheckTestObjects.cs @@ -0,0 +1,5 @@ +using System; + +namespace Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App.HealthChecks +{ +} \ No newline at end of file diff --git a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/IHealthCheckExtensions.cs b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/IHealthCheckExtensions.cs new file mode 100644 index 00000000..2f3fc300 --- /dev/null +++ b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/IHealthCheckExtensions.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Newtonsoft.Json; +using System; +using System.Linq; +using System.Text; + +namespace Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App.HealthChecks +{ + public static class IHealthCheckExtensions + { + public static IServiceCollection AddTestHealthChecks(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + var healthCheckBuilder = services.AddHealthChecks(); + + healthCheckBuilder.AddDbContextCheck(); + + foreach (var index in Enumerable.Range(0, 20)) + { + // Add multiple test health checks to ensure concurrent resolver access doesn't blow up + healthCheckBuilder.AddTestHealthCheck($"TestHealthCheck{index}"); + } + + return services; + } + + public static IHealthChecksBuilder AddTestHealthCheck(this IHealthChecksBuilder builder, string registrationName) + { + builder.Add(new HealthCheckRegistration( + registrationName, + (serviceProvider) => + { + try + { + // Get some different objects from the service provider to try and trigger any thread + // safety issues in the container resolution logic + var healthCheckLock = serviceProvider.GetRequiredService(); + healthCheckLock.DoWorkInsideLock(); + + var testObject1 = serviceProvider.GetRequiredService(); + var testObject2 = serviceProvider.GetRequiredService(); + var testObject3 = serviceProvider.GetRequiredService(); + return new SuccessHealthCheck(registrationName); + } + catch (Exception exc) + { + return new SetupFailedHealthCheck(exc, registrationName); + } + }, + HealthStatus.Unhealthy, + default)); + + return builder; + } + + public static string CreateHealthReportPlainText(string key, HealthReportEntry entry) + { + var entryOutput = new StringBuilder($"{key}: {entry.Status} | {entry.Duration}\n"); + if (entry.Tags?.Any() == true) + { + entryOutput.Append("- Tags:"); + entryOutput.Append(string.Join(", ", entry.Tags)); + entryOutput.Append('\n'); + } + + if (!string.IsNullOrWhiteSpace(entry.Description)) + { + entryOutput.Append($"- Description: {entry.Description}\n\n"); + } + + if (entry.Exception != null) + { + entryOutput.Append($"- Exception: {entry.Exception}\n\n"); + } + + if (entry.Data?.Count > 0) + { + entryOutput.Append("- Data:\n"); + foreach (var keyValuePair in entry.Data) + { + entryOutput.Append($"\t{keyValuePair.Key}: {keyValuePair.Value}"); + } + entryOutput.Append('\n'); + } + + return entryOutput.ToString(); + } + + public static IEndpointRouteBuilder MapTestHealthChecks(this IEndpointRouteBuilder endpoints) + { + endpoints.MapHealthChecks("/health", new HealthCheckOptions + { + ResponseWriter = async (context, report) => + { + var serializableReport = new SerializableHealthCheckResult(report); + var resultString = JsonConvert.SerializeObject(serializableReport); + + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(resultString).ConfigureAwait(false); + } + }); + + return endpoints; + } + } +} diff --git a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SerializableHealthCheckResult.cs b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SerializableHealthCheckResult.cs new file mode 100644 index 00000000..83d2a72c --- /dev/null +++ b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SerializableHealthCheckResult.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App.HealthChecks +{ + public class SerializableHealthCheckResult + { + // Default constructor for json serialization / deserialization support + public SerializableHealthCheckResult() { } + + public SerializableHealthCheckResult(HealthReport healthReport) + { + _ = healthReport ?? throw new ArgumentNullException(nameof(healthReport)); + + Status = healthReport.Status; + TotalDuration = healthReport.TotalDuration; + + if (healthReport.Entries != null) + { + Entries = healthReport.Entries.Select(entry => new SerializableHealthCheckResultEntry(entry.Value, entry.Key)).ToList(); + } + } + + public List Entries { get; set; } + public HealthStatus Status { get; set; } + public TimeSpan TotalDuration { get; set; } + } + + public class SerializableHealthCheckResultEntry + { + // Default constructor for json serialization / deserialization support + public SerializableHealthCheckResultEntry() { } + + public SerializableHealthCheckResultEntry(HealthReportEntry entry, string name) + { + Description = entry.Description; + Duration = entry.Duration; + Exception = entry.Exception?.ToString(); + Name = name; + Status = entry.Status; + Tags = entry.Tags?.ToList(); + } + + public string Description { get; set; } + public TimeSpan Duration { get; set; } + public string Exception { get; set; } + public string Name { get; set; } + public HealthStatus Status { get; set; } + public List Tags { get; set; } + } +} diff --git a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SetupFailedHealthCheck.cs b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SetupFailedHealthCheck.cs new file mode 100644 index 00000000..5a16c06c --- /dev/null +++ b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SetupFailedHealthCheck.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App.HealthChecks +{ + public class SetupFailedHealthCheck : IHealthCheck + { + private readonly Exception _exception; + private readonly string _registrationName; + + public SetupFailedHealthCheck(Exception exception, string registrationName) + { + _exception = exception; + _registrationName = registrationName; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + return Task.FromResult(new HealthCheckResult( + HealthStatus.Unhealthy, + description: $"An exception occurred while attempting to construct the health check for registration: {_registrationName}", + exception: _exception + )); + } + } +} diff --git a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SuccessHealthCheck.cs b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SuccessHealthCheck.cs new file mode 100644 index 00000000..04a333c3 --- /dev/null +++ b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/HealthChecks/SuccessHealthCheck.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App.HealthChecks +{ + public class SuccessHealthCheck : IHealthCheck + { + private readonly string _registrationName; + + public SuccessHealthCheck(string registrationName) + { + _registrationName = registrationName; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + return Task.FromResult(new HealthCheckResult( + HealthStatus.Healthy, + description: $"Health check successful for: {_registrationName}" + )); + } + } + + public class HealthCheckTestObject1 + { + private readonly HealthCheckTestChild1 _child1; + + public HealthCheckTestObject1(HealthCheckTestChild1 child1) + { + _child1 = child1 ?? throw new ArgumentNullException(nameof(child1)); + } + } + + public class HealthCheckTestChild1 + { + public HealthCheckTestChild1() + { + } + } + + public class HealthCheckTestObject2 + { + private readonly HealthCheckTestChild2 _child2; + + public HealthCheckTestObject2(HealthCheckTestChild2 child2) + { + _child2 = child2 ?? throw new ArgumentNullException(nameof(child2)); + } + } + + public class HealthCheckTestChild2 + { + private readonly Context _context; + + public HealthCheckTestChild2(Context context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + } + + public class HealthCheckTestObject3 + { + public HealthCheckTestObject3() + { + } + } + + public class HealthCheckLock + { + private object _lock = new object(); + private long? _result = null; + + public long DoWorkInsideLock() + { + if (_result != null) + { + return _result.Value; + } + + lock (_lock) + { + if (_result != null) + { + return _result.Value; + } + + var stopWatch = new Stopwatch(); + stopWatch.Start(); + while (stopWatch.ElapsedMilliseconds < 2000) + { + continue; + } + stopWatch.Stop(); + _result = stopWatch.ElapsedMilliseconds; + + return _result.Value; + } + } + } +} diff --git a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/LamarStartup.cs b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/LamarStartup.cs index d84f3ca8..e21f2399 100644 --- a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/LamarStartup.cs +++ b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/LamarStartup.cs @@ -1,36 +1,45 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App.HealthChecks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; namespace Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App { - public class LamarStartup - { - public void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(); - services.AddDbContext(); + public class LamarStartup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(); + services.AddDbContext(); - services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0); - } + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0); - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseRouting(); - app.UseEndpoints(configure => configure.MapControllers()); - } + services.AddTestHealthChecks(); + } - public void ConfigureContainer(ServiceRegistry services) - { - services.For().Use().Transient(); - services.For().Use().Transient(); - services.For().Use().Transient(); - } - } + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseRouting(); + app.UseEndpoints(configure => + { + configure.MapControllers(); + configure.MapTestHealthChecks(); + }); + } + + public void ConfigureContainer(ServiceRegistry services) + { + services.For().Use().Transient(); + services.For().Use().Transient(); + services.For().Use().Transient(); + + services.For().Use().Scoped(); + services.For().Use().Scoped(); + services.For().Use().Scoped(); + services.For().Use().Scoped(); + services.For().Use().Scoped(); + services.For().Use().Scoped(); + } + } } diff --git a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/MicrosoftDIStartup.cs b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/MicrosoftDIStartup.cs index ac4b69a0..16687019 100644 --- a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/MicrosoftDIStartup.cs +++ b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/App/MicrosoftDIStartup.cs @@ -1,32 +1,31 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; namespace Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App { - public class MicrosoftDIStartup - { - public void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(); - services.AddDbContext(); + public class MicrosoftDIStartup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(); + services.AddDbContext(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); - services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0); - } + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0); + } - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseRouting(); - app.UseEndpoints(configure => configure.MapControllers()); - } - } + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseRouting(); + app.UseEndpoints(configure => + { + configure.MapControllers(); + }); + } + } } diff --git a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/IntegrationTests.cs b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/IntegrationTests.cs index 4fdd9b52..7cebfe9b 100644 --- a/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/IntegrationTests.cs +++ b/src/Lamar.AspNetCoreTests.Integration/MultiThreadProblem/IntegrationTests.cs @@ -10,6 +10,11 @@ using Microsoft.Extensions.DependencyInjection; using Lamar.Microsoft.DependencyInjection; using Xunit; +using Shouldly; +using Newtonsoft.Json; +using Lamar.AspNetCoreTests.Integration.MultiThreadProblem.App.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit.Abstractions; namespace Lamar.AspNetCoreTests.Integration.MultiThreadProblem { @@ -35,9 +40,12 @@ public IntegrationTestsMicrosoftDI(CustomWebApplicationFactory> { private readonly WebApplicationFactory _factory; + private readonly ITestOutputHelper _output; - public IntegrationTestsLamar(CustomWebApplicationFactory factory) + public IntegrationTestsLamar(CustomWebApplicationFactory factory, ITestOutputHelper output) { + _output = output; + factory.UseLamar = true; _factory = factory.WithWebHostBuilder(builder => { @@ -50,7 +58,7 @@ public IntegrationTestsLamar(CustomWebApplicationFactory factory) } [Fact] - public async void ExecutesInParallel_WithoutExceptions() + public async void ControllerRequest_ExecutesInParallel_WithoutExceptions() { var client = _factory.CreateClient(); var tasks = new List(); @@ -61,5 +69,34 @@ public async void ExecutesInParallel_WithoutExceptions() await Task.WhenAll(tasks); } + + [Fact] + public async Task HealthCheckRequest_completes_successfully() + { + var client = _factory.CreateClient(); + + for (int i = 0; i < 20; ++i) + { + var result = await client.GetAsync("health").ConfigureAwait(false); + + var responseString = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + var responseObject = JsonConvert.DeserializeObject(responseString); + + if (responseObject.Status != HealthStatus.Healthy) + { + var unhealthyEntries = responseObject.Entries.Where(entry => entry.Status != HealthStatus.Healthy).ToList(); + foreach (var unhealthyEntry in unhealthyEntries) + { + _output.WriteLine($"Unhealth Entry ({unhealthyEntry.Name}): {unhealthyEntry.Description}\n{unhealthyEntry.Exception}\n"); + } + +#if DEBUG + System.Diagnostics.Debugger.Break(); +#endif + } + + responseObject.Status.ShouldBe(HealthStatus.Healthy); + } + } } } diff --git a/src/Lamar/IoC/Instances/GeneratedInstance.cs b/src/Lamar/IoC/Instances/GeneratedInstance.cs index 405e179b..33c9b45c 100644 --- a/src/Lamar/IoC/Instances/GeneratedInstance.cs +++ b/src/Lamar/IoC/Instances/GeneratedInstance.cs @@ -98,7 +98,7 @@ public Func BuildFuncResolver(Scope scope) } service = resolver(s); - s.Services.Add(Hash, service); + s.Services.TryAdd(Hash, service); return service; } @@ -126,7 +126,7 @@ public Func BuildFuncResolver(Scope scope) } service = resolver(root); - root.Services.Add(Hash, service); + root.Services.TryAdd(Hash, service); return service; } diff --git a/src/Lamar/IoC/Instances/Instance.cs b/src/Lamar/IoC/Instances/Instance.cs index 49de677d..e793a995 100644 --- a/src/Lamar/IoC/Instances/Instance.cs +++ b/src/Lamar/IoC/Instances/Instance.cs @@ -234,9 +234,9 @@ protected bool tryGetService(Scope scope, out object service) return scope.Services.TryGetValue(Hash, out service); } - protected void store(Scope scope, object service) + protected bool store(Scope scope, object service) { - scope.Services.Add(Hash, service); + return scope.Services.TryAdd(Hash, service); } /// diff --git a/src/Lamar/IoC/Resolvers/ScopedResolver.cs b/src/Lamar/IoC/Resolvers/ScopedResolver.cs index 58b6a75c..fdd6370f 100644 --- a/src/Lamar/IoC/Resolvers/ScopedResolver.cs +++ b/src/Lamar/IoC/Resolvers/ScopedResolver.cs @@ -23,7 +23,7 @@ public object Resolve(Scope scope) } service = (T) Build(scope); - scope.Services.Add(Hash, service); + scope.Services.TryAdd(Hash, service); if (service is IDisposable) { diff --git a/src/Lamar/IoC/Scope.cs b/src/Lamar/IoC/Scope.cs index 3eb7bf23..17c63c4b 100644 --- a/src/Lamar/IoC/Scope.cs +++ b/src/Lamar/IoC/Scope.cs @@ -101,9 +101,9 @@ protected void assertNotDisposed() public ConcurrentBag Disposables { get; } = new ConcurrentBag(); - internal readonly Dictionary Services = new Dictionary(); + internal readonly ConcurrentDictionary Services = new ConcurrentDictionary(); + - public virtual void Dispose() {