diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.EntityFrameworkCore/DatabaseHealthCheck.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.EntityFrameworkCore/DatabaseHealthCheck.cs index 172c041..d0a150d 100644 --- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.EntityFrameworkCore/DatabaseHealthCheck.cs +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.EntityFrameworkCore/DatabaseHealthCheck.cs @@ -24,6 +24,11 @@ namespace ZB.MOM.WW.Health.EntityFrameworkCore; /// disposed context); otherwise a scoped is resolved from a new DI /// scope. Recommended registration tag: ZbHealthTags.Ready (applied by the registrant). /// +/// +/// The scoped-resolution path is safe for AddDbContextPool: disposing the +/// returns the pooled context to the pool rather than destroying it, +/// so no pooled instance is prematurely discarded. +/// /// /// The EF Core to probe. public sealed class DatabaseHealthCheck : IHealthCheck @@ -59,7 +64,11 @@ public sealed class DatabaseHealthCheck : IHealthCheck try { - return await ProbeAsync(timeoutCts.Token).ConfigureAwait(false); + var result = await ProbeAsync(timeoutCts.Token).ConfigureAwait(false); + // Eagerly release the pending timer on the happy path so the OS timer + // resource is not held for the full timeout duration. + timeoutCts.CancelAfter(Timeout.InfiniteTimeSpan); + return result; } catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/DatabaseHealthCheckTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/DatabaseHealthCheckTests.cs index 6cb81df..f3412a7 100644 --- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/DatabaseHealthCheckTests.cs +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/DatabaseHealthCheckTests.cs @@ -10,9 +10,10 @@ namespace ZB.MOM.WW.Health.EntityFrameworkCore.Tests; /// Verifies against a real SQLite database (in-memory, /// connection kept open) so the CanConnectAsync semantics exercise an actual provider: /// reachable → Healthy, unopenable connection → Unhealthy (no throw escapes), a custom -/// that queries → Healthy, and a -/// throwing ProbeQuery → Unhealthy. Both the and -/// the scoped-TContext resolution paths are covered. +/// that queries → Healthy, a +/// throwing ProbeQuery → Unhealthy, and a timed-out probe → Unhealthy. Both the +/// and the scoped-TContext resolution paths +/// are covered. /// public sealed class DatabaseHealthCheckTests { @@ -43,7 +44,7 @@ public sealed class DatabaseHealthCheckTests /// SQLite connection (and creates the schema). When is true the /// context is registered via AddDbContextFactory; otherwise via AddDbContext (scoped). /// - private static IServiceProvider BuildProvider(SqliteConnection connection, bool useFactory) + private static ServiceProvider BuildProvider(SqliteConnection connection, bool useFactory) { connection.Open(); @@ -71,7 +72,7 @@ public sealed class DatabaseHealthCheckTests public async Task ReachableContext_Healthy(bool useFactory) { using var connection = new SqliteConnection("DataSource=:memory:"); - var provider = BuildProvider(connection, useFactory); + await using var provider = BuildProvider(connection, useFactory); var check = new DatabaseHealthCheck(provider); @@ -88,7 +89,7 @@ public sealed class DatabaseHealthCheckTests var services = new ServiceCollection(); services.AddDbContext(o => o.UseSqlite($"DataSource={bogusPath};Mode=ReadWrite")); - var provider = services.BuildServiceProvider(); + await using var provider = services.BuildServiceProvider(); var check = new DatabaseHealthCheck(provider); @@ -101,7 +102,7 @@ public sealed class DatabaseHealthCheckTests public async Task CustomProbeQuery_RunsQuery_Healthy() { using var connection = new SqliteConnection("DataSource=:memory:"); - var provider = BuildProvider(connection, useFactory: true); + await using var provider = BuildProvider(connection, useFactory: true); var options = new DatabaseHealthCheckOptions { @@ -118,11 +119,32 @@ public sealed class DatabaseHealthCheckTests public async Task ProbeQueryThrows_Unhealthy() { using var connection = new SqliteConnection("DataSource=:memory:"); - var provider = BuildProvider(connection, useFactory: false); + await using var provider = BuildProvider(connection, useFactory: false); var options = new DatabaseHealthCheckOptions { - ProbeQuery = (_, _) => throw new InvalidOperationException("boom"), + // Use a faulted task rather than a synchronous throw to accurately model + // async probe delegates that encounter an error. + ProbeQuery = (_, _) => Task.FromException(new InvalidOperationException("boom")), + }; + var check = new DatabaseHealthCheck(provider, options); + + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + + [Fact] + public async Task ProbeTimeout_Unhealthy() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + await using var provider = BuildProvider(connection, useFactory: true); + + // Use a very short timeout and a probe that blocks indefinitely (until cancelled). + var options = new DatabaseHealthCheckOptions + { + Timeout = TimeSpan.FromMilliseconds(50), + ProbeQuery = async (_, ct) => await Task.Delay(Timeout.Infinite, ct), }; var check = new DatabaseHealthCheck(provider, options); diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests.csproj b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests.csproj index 1f52ef9..f94b6ee 100644 --- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests.csproj +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests.csproj @@ -9,7 +9,6 @@ -