refactor(health.ef): review polish (timer release, timeout test, provider disposal, drop unused dep)
- Eagerly call CancelAfter(InfiniteTimeSpan) after a successful probe so the pending OS timer is released on the happy path rather than held for the full timeout window. - Add ProbeTimeout_Unhealthy test: 50 ms timeout with an infinite-blocking probe delegate asserts Unhealthy, covering the timeout code path. - Fix ProbeQueryThrows_Unhealthy to use Task.FromException rather than a synchronous throw, accurately modelling a faulted async delegate. - Wrap all BuildServiceProvider() results in await using so ServiceProvider is disposed after each test (no DI provider leak). - Remove unused Microsoft.EntityFrameworkCore.InMemory package reference; tests use SQLite only (InMemory CanConnect semantics differ and the package was not exercised). - Add <remarks> to DatabaseHealthCheck<TContext> noting the scoped-resolution path is safe for AddDbContextPool (scope dispose returns context to pool, not destroys it).
This commit is contained in:
@@ -24,6 +24,11 @@ namespace ZB.MOM.WW.Health.EntityFrameworkCore;
|
|||||||
/// disposed context); otherwise a scoped <typeparamref name="TContext"/> is resolved from a new DI
|
/// disposed context); otherwise a scoped <typeparamref name="TContext"/> is resolved from a new DI
|
||||||
/// scope. Recommended registration tag: <c>ZbHealthTags.Ready</c> (applied by the registrant).
|
/// scope. Recommended registration tag: <c>ZbHealthTags.Ready</c> (applied by the registrant).
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The scoped-resolution path is safe for <c>AddDbContextPool</c>: disposing the
|
||||||
|
/// <see cref="IServiceScope"/> returns the pooled context to the pool rather than destroying it,
|
||||||
|
/// so no pooled instance is prematurely discarded.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <typeparam name="TContext">The EF Core <see cref="DbContext"/> to probe.</typeparam>
|
/// <typeparam name="TContext">The EF Core <see cref="DbContext"/> to probe.</typeparam>
|
||||||
public sealed class DatabaseHealthCheck<TContext> : IHealthCheck
|
public sealed class DatabaseHealthCheck<TContext> : IHealthCheck
|
||||||
@@ -59,7 +64,11 @@ public sealed class DatabaseHealthCheck<TContext> : IHealthCheck
|
|||||||
|
|
||||||
try
|
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)
|
catch (OperationCanceledException ex)
|
||||||
when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||||
|
|||||||
+31
-9
@@ -10,9 +10,10 @@ namespace ZB.MOM.WW.Health.EntityFrameworkCore.Tests;
|
|||||||
/// Verifies <see cref="DatabaseHealthCheck{TContext}"/> against a real SQLite database (in-memory,
|
/// Verifies <see cref="DatabaseHealthCheck{TContext}"/> against a real SQLite database (in-memory,
|
||||||
/// connection kept open) so the <c>CanConnectAsync</c> semantics exercise an actual provider:
|
/// connection kept open) so the <c>CanConnectAsync</c> semantics exercise an actual provider:
|
||||||
/// reachable → Healthy, unopenable connection → Unhealthy (no throw escapes), a custom
|
/// reachable → Healthy, unopenable connection → Unhealthy (no throw escapes), a custom
|
||||||
/// <see cref="DatabaseHealthCheckOptions{TContext}.ProbeQuery"/> that queries → Healthy, and a
|
/// <see cref="DatabaseHealthCheckOptions{TContext}.ProbeQuery"/> that queries → Healthy, a
|
||||||
/// throwing <c>ProbeQuery</c> → Unhealthy. Both the <see cref="IDbContextFactory{TContext}"/> and
|
/// throwing <c>ProbeQuery</c> → Unhealthy, and a timed-out probe → Unhealthy. Both the
|
||||||
/// the scoped-<c>TContext</c> resolution paths are covered.
|
/// <see cref="IDbContextFactory{TContext}"/> and the scoped-<c>TContext</c> resolution paths
|
||||||
|
/// are covered.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DatabaseHealthCheckTests
|
public sealed class DatabaseHealthCheckTests
|
||||||
{
|
{
|
||||||
@@ -43,7 +44,7 @@ public sealed class DatabaseHealthCheckTests
|
|||||||
/// SQLite connection (and creates the schema). When <paramref name="useFactory"/> is true the
|
/// SQLite connection (and creates the schema). When <paramref name="useFactory"/> is true the
|
||||||
/// context is registered via <c>AddDbContextFactory</c>; otherwise via <c>AddDbContext</c> (scoped).
|
/// context is registered via <c>AddDbContextFactory</c>; otherwise via <c>AddDbContext</c> (scoped).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IServiceProvider BuildProvider(SqliteConnection connection, bool useFactory)
|
private static ServiceProvider BuildProvider(SqliteConnection connection, bool useFactory)
|
||||||
{
|
{
|
||||||
connection.Open();
|
connection.Open();
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ public sealed class DatabaseHealthCheckTests
|
|||||||
public async Task ReachableContext_Healthy(bool useFactory)
|
public async Task ReachableContext_Healthy(bool useFactory)
|
||||||
{
|
{
|
||||||
using var connection = new SqliteConnection("DataSource=:memory:");
|
using var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
var provider = BuildProvider(connection, useFactory);
|
await using var provider = BuildProvider(connection, useFactory);
|
||||||
|
|
||||||
var check = new DatabaseHealthCheck<WidgetContext>(provider);
|
var check = new DatabaseHealthCheck<WidgetContext>(provider);
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ public sealed class DatabaseHealthCheckTests
|
|||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddDbContext<WidgetContext>(o => o.UseSqlite($"DataSource={bogusPath};Mode=ReadWrite"));
|
services.AddDbContext<WidgetContext>(o => o.UseSqlite($"DataSource={bogusPath};Mode=ReadWrite"));
|
||||||
var provider = services.BuildServiceProvider();
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
var check = new DatabaseHealthCheck<WidgetContext>(provider);
|
var check = new DatabaseHealthCheck<WidgetContext>(provider);
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ public sealed class DatabaseHealthCheckTests
|
|||||||
public async Task CustomProbeQuery_RunsQuery_Healthy()
|
public async Task CustomProbeQuery_RunsQuery_Healthy()
|
||||||
{
|
{
|
||||||
using var connection = new SqliteConnection("DataSource=:memory:");
|
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<WidgetContext>
|
var options = new DatabaseHealthCheckOptions<WidgetContext>
|
||||||
{
|
{
|
||||||
@@ -118,11 +119,32 @@ public sealed class DatabaseHealthCheckTests
|
|||||||
public async Task ProbeQueryThrows_Unhealthy()
|
public async Task ProbeQueryThrows_Unhealthy()
|
||||||
{
|
{
|
||||||
using var connection = new SqliteConnection("DataSource=:memory:");
|
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<WidgetContext>
|
var options = new DatabaseHealthCheckOptions<WidgetContext>
|
||||||
{
|
{
|
||||||
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<WidgetContext>(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<WidgetContext>
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(50),
|
||||||
|
ProbeQuery = async (_, ct) => await Task.Delay(Timeout.Infinite, ct),
|
||||||
};
|
};
|
||||||
var check = new DatabaseHealthCheck<WidgetContext>(provider, options);
|
var check = new DatabaseHealthCheck<WidgetContext>(provider, options);
|
||||||
|
|
||||||
|
|||||||
-1
@@ -9,7 +9,6 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user